gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 0746ef741a51bd8f92ca5e07dfb9f35b66f4cf06
parent 6cf6613540cb8a3f7876a51ee6edaa7afee69905
Author: f0x52 <f0x@cthu.lu>
Date:   Wed, 29 Mar 2023 12:18:45 +0200

[frontend] Settings navigation design (#1652)

* change header image alignment

(cherry picked from commit df1bb339a5c597a2b668cedb3dafec5a390df120)

* big mess navigation refactor

* bit of cleanup

* minor css tweaks

* fix error rendering code for remote emoji

* refactor navigation structure code

* refactor styling

* fix className

* stash

* restructure navigation generation

* url wildcard formatting

* remove un-implemented User menu entry

* remove commented lines

* clarify permissions check

* invert permissions logic for clarity
Diffstat:
Mweb/source/css/_colors.css | 12++++++------
Mweb/source/css/base.css | 62++++++++++++++++++++++++++++----------------------------------
Mweb/source/settings/admin/emoji/local/detail.js | 15++++++++-------
Mweb/source/settings/admin/emoji/local/index.js | 10++++------
Mweb/source/settings/admin/emoji/local/overview.js | 12+++++++-----
Mweb/source/settings/admin/emoji/remote/index.js | 3++-
Mweb/source/settings/admin/federation/import-export/form.jsx | 2+-
Mweb/source/settings/admin/federation/import-export/index.jsx | 4+---
Mweb/source/settings/admin/federation/index.js | 4+---
Mweb/source/settings/admin/reports/detail.jsx | 4+++-
Mweb/source/settings/admin/reports/index.jsx | 12++++++------
Dweb/source/settings/components/nav-button.jsx | 35-----------------------------------
Aweb/source/settings/components/user-logout-card.jsx | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/index.js | 78++++++++++++++++++++++++++++++++++++++----------------------------------------
Dweb/source/settings/lib/get-views.js | 103-------------------------------------------------------------------------------
Aweb/source/settings/lib/navigation/components.jsx | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/navigation/index.js | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/navigation/util.js | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/style.css | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mweb/template/about.tmpl | 2+-
20 files changed, 628 insertions(+), 311 deletions(-)

diff --git a/web/source/css/_colors.css b/web/source/css/_colors.css @@ -101,15 +101,15 @@ $input-border: $blue1; $input-error-border: $error3; $input-focus-border: $blue3; -$settings-nav-bg: $bg-accent; -$settings-nav-header-fg: $gray1; -$settings-nav-header-bg: $orange1; +$settings-nav-bg: $bg; +$settings-nav-header-fg: $orange2; $settings-nav-bg-hover: $gray3; -/* $settings-nav-fg-hover: $gray1; */ +$settings-nav-fg-hover: $fg; -$settings-nav-bg-active: $gray2; -/* $settings-nav-fg-active: $orange2; */ +$settings-nav-bg-active: $blue3; +$settings-nav-border-active: $info-bg; +$settings-nav-fg-active: $gray2; $error-fg: $error1; $error-bg: $error2; diff --git a/web/source/css/base.css b/web/source/css/base.css @@ -97,11 +97,10 @@ header { header a { margin: 2rem; display: flex; - flex-direction: column; flex-wrap: wrap; + gap: 2rem; img { - margin-bottom: 1rem; align-self: center; height: 6rem; } @@ -120,7 +119,6 @@ header a { } .excerpt-top { - margin-top: -1rem; margin-bottom: 2rem; font-style: italic; font-weight: normal; @@ -515,41 +513,37 @@ label { margin-bottom: 0; } - .contact-account-card { - /* display: inline-grid; - grid-template-columns: 4rem auto; - grid-template-rows: 4rem; - gap: 1rem; - padding: 0.5rem; */ - display: inline-grid; - grid-template-columns: auto 1fr; - grid-template-rows: auto auto; - text-decoration: none; - gap: 0.5rem 1rem; - border-radius: $br; - padding: 0.5rem; - min-width: 40%; - margin-bottom: 0.3rem; +} - background: $list-entry-bg; +.account-card { + display: inline-grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + text-decoration: none; + gap: 0.5rem 1rem; + border-radius: $br; + padding: 0.5rem; + min-width: 40%; + margin-bottom: 0.3rem; - &:hover { - background: $list-entry-alternate-bg; - } + background: $list-entry-bg; - h3 { - align-self: end; - margin: 0; - color: $fg; - } + &:hover { + background: $list-entry-alternate-bg; + } - img.avatar { - border-radius: 0.5rem; - width: 5rem; - height: 5rem; - object-fit: cover; - grid-row: 1 / span 2; - } + h3 { + align-self: end; + margin: 0; + color: $fg; + } + + img.avatar { + border-radius: 0.5rem; + width: 5rem; + height: 5rem; + object-fit: cover; + grid-row: 1 / span 2; } } diff --git a/web/source/settings/admin/emoji/local/detail.js b/web/source/settings/admin/emoji/local/detail.js @@ -28,6 +28,7 @@ const { useComboBoxInput, useFileInput, useValue } = require("../../../lib/form" const { CategorySelect } = require("../category-select"); const useFormSubmit = require("../../../lib/form/submit"); +const { useBaseUrl } = require("../../../lib/navigation/util"); const FakeToot = require("../../../components/fake-toot"); const FormWithData = require("../../../lib/form/form-with-data"); @@ -36,16 +37,15 @@ const { FileInput } = require("../../../components/form/inputs"); const MutationButton = require("../../../components/form/mutation-button"); const { Error } = require("../../../components/error"); -const base = "/settings/custom-emoji/local"; - -module.exports = function EmojiDetailRoute() { - let [_match, params] = useRoute(`${base}/:emojiId`); +module.exports = function EmojiDetailRoute({ }) { + const baseUrl = useBaseUrl(); + let [_match, params] = useRoute(`${baseUrl}/:emojiId`); if (params?.emojiId == undefined) { - return <Redirect to={base} />; + return <Redirect to={baseUrl} />; } else { return ( <div className="emoji-detail"> - <Link to={base}><a>&lt; go back</a></Link> + <Link to={baseUrl}><a>&lt; go back</a></Link> <FormWithData dataQuery={query.useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} /> </div> ); @@ -53,6 +53,7 @@ module.exports = function EmojiDetailRoute() { }; function EmojiDetailForm({ data: emoji }) { + const baseUrl = useBaseUrl(); const form = { id: useValue("id", emoji.id), category: useComboBoxInput("category", { source: emoji }), @@ -78,7 +79,7 @@ function EmojiDetailForm({ data: emoji }) { const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation(); if (deleteResult.isSuccess) { - return <Redirect to={base} />; + return <Redirect to={baseUrl} />; } return ( diff --git a/web/source/settings/admin/emoji/local/index.js b/web/source/settings/admin/emoji/local/index.js @@ -25,15 +25,13 @@ const { Switch, Route } = require("wouter"); const EmojiOverview = require("./overview"); const EmojiDetail = require("./detail"); -const base = "/settings/custom-emoji/local"; - -module.exports = function CustomEmoji() { +module.exports = function CustomEmoji({ baseUrl }) { return ( <Switch> - <Route path={`${base}/:emojiId`}> - <EmojiDetail baseUrl={base} /> + <Route path={`${baseUrl}/:emojiId`}> + <EmojiDetail /> </Route> - <EmojiOverview baseUrl={base} /> + <EmojiOverview /> </Switch> ); }; diff --git a/web/source/settings/admin/emoji/local/overview.js b/web/source/settings/admin/emoji/local/overview.js @@ -29,12 +29,13 @@ const { useTextInput } = require("../../../lib/form"); const query = require("../../../lib/query"); 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"); -module.exports = function EmojiOverview({ baseUrl }) { +module.exports = function EmojiOverview({ }) { const { data: emoji = [], isLoading, @@ -51,7 +52,7 @@ module.exports = function EmojiOverview({ baseUrl }) { } else { content = ( <> - <EmojiList emoji={emoji} baseUrl={baseUrl} /> + <EmojiList emoji={emoji} /> <NewEmojiForm emoji={emoji} /> </> ); @@ -70,7 +71,7 @@ module.exports = function EmojiOverview({ baseUrl }) { ); }; -function EmojiList({ emoji, baseUrl }) { +function EmojiList({ emoji }) { const filterField = useTextInput("filter"); const filter = filterField.value; @@ -116,7 +117,7 @@ function EmojiList({ emoji, baseUrl }) { ? ( <div className="entries scrolling"> {filteredEmoji.map(([category, entries]) => { - return <EmojiCategory key={category} category={category} entries={entries} baseUrl={baseUrl} />; + return <EmojiCategory key={category} category={category} entries={entries} />; })} </div> ) @@ -128,7 +129,8 @@ function EmojiList({ emoji, baseUrl }) { ); } -function EmojiCategory({ category, entries, baseUrl }) { +function EmojiCategory({ category, entries }) { + const baseUrl = useBaseUrl(); return ( <div className="entry"> <b>{category}</b> diff --git a/web/source/settings/admin/emoji/remote/index.js b/web/source/settings/admin/emoji/remote/index.js @@ -25,6 +25,7 @@ const ParseFromToot = require("./parse-from-toot"); const query = require("../../../lib/query"); const Loading = require("../../../components/loading"); +const { Error } = require("../../../components/error"); module.exports = function RemoteEmoji() { // local emoji are queried for shortcode collision detection @@ -42,7 +43,7 @@ module.exports = function RemoteEmoji() { <> <h1>Custom Emoji (remote)</h1> {error && - <div className="error accent">{error}</div> + <Error error={error} /> } {isLoading ? <Loading /> diff --git a/web/source/settings/admin/federation/import-export/form.jsx b/web/source/settings/admin/federation/import-export/form.jsx @@ -79,7 +79,7 @@ module.exports = function ImportExportForm({ form, submitParse, parseResult }) { showError={false} /> <label className="button with-icon"> - <i class="fa fa-fw " aria-hidden="true" /> + <i className="fa fa-fw " aria-hidden="true" /> Import file <input type="file" diff --git a/web/source/settings/admin/federation/import-export/index.jsx b/web/source/settings/admin/federation/import-export/index.jsx @@ -33,9 +33,7 @@ const useFormSubmit = require("../../../lib/form/submit"); const ProcessImport = require("./process"); const ImportExportForm = require("./form"); -const baseUrl = "/settings/admin/federation/import-export"; - -module.exports = function ImportExport() { +module.exports = function ImportExport({ baseUrl }) { const form = { domains: useTextInput("domains"), exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }) diff --git a/web/source/settings/admin/federation/index.js b/web/source/settings/admin/federation/index.js @@ -22,13 +22,11 @@ const React = require("react"); const { Switch, Route } = require("wouter"); -const baseUrl = `/settings/admin/federation`; - const InstanceOverview = require("./overview"); const InstanceDetail = require("./detail"); const InstanceImportExport = require("./import-export"); -module.exports = function Federation({ }) { +module.exports = function Federation({ baseUrl }) { return ( <Switch> <Route path={`${baseUrl}/import-export/:list?`}> diff --git a/web/source/settings/admin/reports/detail.jsx b/web/source/settings/admin/reports/detail.jsx @@ -34,8 +34,10 @@ const { TextArea } = require("../../components/form/inputs"); const MutationButton = require("../../components/form/mutation-button"); const Username = require("./username"); +const { useBaseUrl } = require("../../lib/navigation/util"); -module.exports = function ReportDetail({ baseUrl }) { +module.exports = function ReportDetail({ }) { + const baseUrl = useBaseUrl(); let [_match, params] = useRoute(`${baseUrl}/:reportId`); if (params?.reportId == undefined) { return <Redirect to={baseUrl} />; diff --git a/web/source/settings/admin/reports/index.jsx b/web/source/settings/admin/reports/index.jsx @@ -28,23 +28,22 @@ const FormWithData = require("../../lib/form/form-with-data"); const ReportDetail = require("./detail"); const Username = require("./username"); +const { useBaseUrl } = require("../../lib/navigation/util"); -const baseUrl = "/settings/admin/reports"; - -module.exports = function Reports() { +module.exports = function Reports({ baseUrl }) { return ( <div className="reports"> <Switch> <Route path={`${baseUrl}/:reportId`}> - <ReportDetail baseUrl={baseUrl} /> + <ReportDetail /> </Route> - <ReportOverview baseUrl={baseUrl} /> + <ReportOverview /> </Switch> </div> ); }; -function ReportOverview({ _baseUrl }) { +function ReportOverview({ }) { return ( <> <h1>Reports</h1> @@ -79,6 +78,7 @@ function ReportsList({ data: reports }) { } function ReportEntry({ report }) { + const baseUrl = useBaseUrl(); const from = report.account; const target = report.target_account; diff --git a/web/source/settings/components/nav-button.jsx b/web/source/settings/components/nav-button.jsx @@ -1,34 +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/>. -*/ - -"use strict"; - -const React = require("react"); -const { Link, useRoute } = require("wouter"); - -module.exports = function NavButton({ href, name }) { - const [isActive] = useRoute(`${href}/:anything?`); - return ( - <Link href={href}> - <a className={isActive ? "active" : ""} data-content={name}> - {name} - </a> - </Link> - ); -}; -\ No newline at end of file diff --git a/web/source/settings/components/user-logout-card.jsx b/web/source/settings/components/user-logout-card.jsx @@ -0,0 +1,47 @@ +/* + 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/>. +*/ + +"use strict"; + +const React = require("react"); + +const query = require("../lib/query"); + +const Loading = require("./loading"); + +module.exports = function UserLogoutCard() { + const { data: profile, isLoading } = query.useVerifyCredentialsQuery(); + const { data: instance } = query.useInstanceQuery(); + const [logoutQuery] = query.useLogoutMutation(); + + if (isLoading) { + return <Loading />; + } else { + return ( + <div className="account-card"> + <img className="avatar" src={profile.avatar} alt="" /> + <h3 className="text-cutoff">{profile.display_name?.length > 0 ? profile.display_name : profile.acct}</h3> + <span className="text-cutoff">@{profile.username}@{instance?.account_domain}</span> + <a onClick={logoutQuery} href="#" aria-label="Log out" title="Log out" className="logout"> + <i className="fa fa-fw fa-sign-out" aria-hidden="true" /> + </a> + </div> + ); + } +}; +\ No newline at end of file diff --git a/web/source/settings/index.js b/web/source/settings/index.js @@ -23,61 +23,59 @@ const React = require("react"); const ReactDom = require("react-dom/client"); const { Provider } = require("react-redux"); const { PersistGate } = require("redux-persist/integration/react"); -const { Switch, Route, Redirect } = require("wouter"); - -const query = require("./lib/query"); const { store, persistor } = require("./redux"); +const { createNavigation, Menu, Item } = require("./lib/navigation"); + const AuthorizationGate = require("./components/authorization"); const Loading = require("./components/loading"); +const UserLogoutCard = require("./components/user-logout-card"); +const { RoleContext } = require("./lib/navigation/util"); require("./style.css"); -// TODO: nested categories? -const nav = { - "User": { - "Profile": require("./user/profile.js"), - "Settings": require("./user/settings.js"), - }, - "Admin": { - adminOnly: true, - "Instance Settings": require("./admin/settings.js"), - "Actions": require("./admin/actions"), - "Federation": require("./admin/federation"), - "Reports": require("./admin/reports") - }, - "Custom Emoji": { - adminOnly: true, - "Local": require("./admin/emoji/local"), - "Remote": require("./admin/emoji/remote"), - } -}; - -const { sidebar, panelRouter } = require("./lib/get-views")(nav); +const { Sidebar, ViewRouter } = createNavigation("/settings", [ + Menu("User", [ + Item("Profile", { icon: "fa-user" }, require("./user/profile")), + Item("Settings", { icon: "fa-cogs" }, require("./user/settings")), + ]), + Menu("Moderation", { + url: "admin", + permissions: ["admin"] + }, [ + Item("Reports", { icon: "fa-flag", wildcard: true }, require("./admin/reports")), + Menu("Federation", { icon: "fa-hubzilla" }, [ + Item("Federation", { icon: "fa-hubzilla", url: "", wildcard: true }, require("./admin/federation")), + Item("Import/Export", { icon: "fa-floppy-o", wildcard: true }, require("./admin/federation/import-export")), + ]) + ]), + Menu("Administration", { + url: "admin", + defaultUrl: "/settings/admin/settings", + permissions: ["admin"] + }, [ + Item("Actions", { icon: "fa-bolt" }, require("./admin/actions")), + Menu("Custom Emoji", { icon: "fa-smile-o" }, [ + Item("Local", { icon: "fa-home", wildcard: true }, require("./admin/emoji/local")), + Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote")) + ]), + Item("Settings", { icon: "fa-sliders" }, require("./admin/settings")) + ]) +]); function App({ account }) { - const isAdmin = account.role.name == "admin"; - const [logoutQuery] = query.useLogoutMutation(); + const permissions = [account.role.name]; return ( - <> + <RoleContext.Provider value={permissions}> <div className="sidebar"> - {sidebar.all} - {isAdmin && sidebar.admin} - <button className="logout" onClick={logoutQuery}> - Log out - </button> + <UserLogoutCard /> + <Sidebar /> </div> <section className="with-sidebar"> - <Switch> - {panelRouter.all} - {isAdmin && panelRouter.admin} - <Route> - <Redirect to="/settings/user" /> - </Route> - </Switch> + <ViewRouter /> </section> - </> + </RoleContext.Provider> ); } diff --git a/web/source/settings/lib/get-views.js b/web/source/settings/lib/get-views.js @@ -1,102 +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/>. -*/ - -"use strict"; - -const React = require("react"); -const { Link, Route, Redirect } = require("wouter"); -const { ErrorBoundary } = require("react-error-boundary"); - -const { ErrorFallback } = require("../components/error"); -const NavButton = require("../components/nav-button"); - -function urlSafe(str) { - return str.toLowerCase().replace(/\s+/g, "-"); -} - -module.exports = function getViews(struct) { - const sidebar = { - all: [], - admin: [], - }; - - const panelRouter = { - all: [], - admin: [], - }; - - Object.entries(struct).forEach(([name, entries]) => { - let sidebarEl = sidebar.all; - let panelRouterEl = panelRouter.all; - - if (entries.adminOnly) { - sidebarEl = sidebar.admin; - panelRouterEl = panelRouter.admin; - delete entries.adminOnly; - } - - let base = `/settings/${urlSafe(name)}`; - - let links = []; - - let firstRoute; - - Object.entries(entries).forEach(([name, ViewComponent]) => { - let url = `${base}/${urlSafe(name)}`; - - if (firstRoute == undefined) { - firstRoute = url; - } - - panelRouterEl.push(( - <Route path={`${url}/:page*`} key={url}> - <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}> - {/* FIXME: implement onReset */} - <ViewComponent /> - </ErrorBoundary> - </Route> - )); - - links.push( - <NavButton key={url} href={url} name={name} /> - ); - }); - - panelRouterEl.push( - <Route key={base} path={base}> - <Redirect to={firstRoute} /> - </Route> - ); - - sidebarEl.push( - <React.Fragment key={name}> - <Link href={firstRoute}> - <a> - <h2>{name}</h2> - </a> - </Link> - <nav> - {links} - </nav> - </React.Fragment> - ); - }); - - return { sidebar, panelRouter }; -}; -\ No newline at end of file diff --git a/web/source/settings/lib/navigation/components.jsx b/web/source/settings/lib/navigation/components.jsx @@ -0,0 +1,141 @@ +/* + 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/>. +*/ + +"use strict"; + +const React = require("react"); +const { Link, Route, Redirect, Switch, useLocation, useRouter } = require("wouter"); +const { ErrorBoundary } = require("react-error-boundary"); +const syncpipe = require("syncpipe"); + +const { ErrorFallback } = require("../../components/error"); + +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 FallbackComponent={ErrorFallback} onReset={() => { }}> + {/* FIXME: implement onReset */} + <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> + ); +} + +module.exports = { + Sidebar, + ViewRouter, + MenuComponent +}; +\ No newline at end of file diff --git a/web/source/settings/lib/navigation/index.js b/web/source/settings/lib/navigation/index.js @@ -0,0 +1,138 @@ +/* + 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/>. +*/ + +"use strict"; + +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/util.js b/web/source/settings/lib/navigation/util.js @@ -0,0 +1,51 @@ +/* + 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/>. +*/ + +"use strict"; + +const React = require("react"); +const RoleContext = React.createContext([]); +const BaseUrlContext = React.createContext(null); + +function urlSafe(str) { + return str.toLowerCase().replace(/[\s/]+/g, "-"); +} + +function useHasPermission(permissions) { + const roles = React.useContext(RoleContext); + return checkPermission(permissions, roles); +} + +function checkPermission(requiredPermissisons, user) { + // requiredPermissions can be 'false', in which case there are no restrictions + if (requiredPermissisons === false) { + return true; + } + + // or an array of roles, check if one of the user's roles is sufficient + return user.some((role) => requiredPermissisons.includes(role)); +} + +function useBaseUrl() { + return React.useContext(BaseUrlContext); +} + +module.exports = { + urlSafe, RoleContext, useHasPermission, checkPermission, BaseUrlContext, useBaseUrl +}; +\ No newline at end of file diff --git a/web/source/settings/style.css b/web/source/settings/style.css @@ -34,18 +34,36 @@ section { grid-column: 2; } +header { + justify-content: start; + + a { + margin: 1.5rem; + gap: 1rem; + + h1 { + font-size: 1.5rem; + } + + img { + height: 3rem; + } + } +} + +main section { + box-shadow: none; + border-radius: none; + border: none; +} + #root { display: grid; - /* keep in sync with base.css .page {} */ - grid-template-columns: auto minmax(auto, 50rem) auto; - grid-template-columns: auto min(92%, 50rem) auto; + grid-template-columns: 1fr minmax(auto, 60rem) 1fr; + grid-template-columns: 1fr min(92%, 60rem) 1fr; box-sizing: border-box; section.with-sidebar { - border-left: none; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - & > div, & > form { border-left: 0.2rem solid $border-accent; padding-left: 0.4rem; @@ -79,77 +97,141 @@ section { } .sidebar { + margin: 0 1rem; align-self: start; justify-self: end; - background: $settings-nav-bg; - border: $boxshadow-border; - box-shadow: $boxshadow; - border-radius: $br; - border-top-right-radius: 0; - border-bottom-right-radius: 0; + background: $bg; display: flex; flex-direction: column; min-width: 12rem; - a { - text-decoration: none; - } + .account-card { + grid-template-columns: auto 1fr auto; - a:first-child h2 { - border-top-left-radius: $br; + img.avatar { + width: 4rem; + height: 4rem; + } + + span { + grid-row: 2; + } + + .logout { + font-size: 1.5rem; + align-self: center; + grid-row: 1 / span 2; + } + + &:hover { + background: $list-entry-bg; + } } + } +} - h2 { - margin: 0; +nav.menu-tree { + ul { + display: flex; + flex-direction: column; + list-style-type: none; + margin: 0; + padding: 0; + } + + .icon { + margin-right: 0.5rem; + } + + /* top-level ul */ + & > ul { + gap: 0.3rem; + padding: 0.2rem; + } + + li.top-level { /* top-level categories, orange all-caps titles */ + border-top: 0.1rem solid $gray3; + display: flex; + flex-direction: column; + gap: 0.3rem; + margin: 0; + + & > a.title { + text-decoration: none; + color: $settings-nav-header-fg; padding: 0.5rem; - font-size: 0.9rem; + padding-bottom: 0; + margin: 0; + font-size: 0.8rem; font-weight: bold; text-transform: uppercase; - color: $settings-nav-header-fg; - background: $settings-nav-header-bg; } - - nav { - display: flex; - flex-direction: column; + + & > ul { + gap: 0.2rem; + } + } + + li.expanding { /* second-level categories, expanding box, active shows nested */ + a { + display: block; + color: $fg; + text-decoration: none; + + border: 0.1rem solid transparent; + border-radius: $br; + padding: 0.5rem; + transition: background 0.1s; + + &:hover { + color: $settings-nav-fg-hover; + background: $settings-nav-bg-hover; + } + + &:focus, &:active { + border-color: $settings-nav-border-active; + outline: none; + } + } + + &.active { + border: 0.1rem solid $settings-nav-border-active; + border-radius: $br; + overflow: hidden; a { - padding: 1rem; - text-decoration: none; - transition: 0.1s; - color: $fg; - - &:hover { - color: $settings-nav-fg-hover; - background: $settings-nav-bg-hover; - } - - &.active { - color: $settings-nav-fg-active; - background: $settings-nav-bg-active; - font-weight: bold; - text-decoration: underline; - } - - /* reserve space for bold version of the element, so .active doesn't - change container size */ - &::after { - font-weight: bold; - text-decoration: underline; - display: block; - content: attr(data-content); - height: 1px; - color: transparent; - overflow: hidden; - visibility: hidden; - } + transition: background 0s; + border: none; + color: $settings-nav-fg-active; + background: $settings-nav-bg-active; + font-weight: bold; + border-radius: 0; } } + } + li.nested { /* any deeper nesting, just has indent */ + a.title { + padding-left: 1rem; + font-weight: normal; + color: $fg; + background: $gray4; - nav:last-child a:last-child { - border-bottom-left-radius: $br; - border-bottom: none; + &:focus { + color: $fg-accent; + outline: none; + } + + &:hover { + background: $settings-nav-bg-hover; + } + } + + &.active { + a.title { + color: $fg-accent; + font-weight: bold; + } } } } diff --git a/web/template/about.tmpl b/web/template/about.tmpl @@ -27,7 +27,7 @@ <div> <h2>Admin Contact</h2> {{if .instance.ContactAccount}} - <a href="{{.instance.ContactAccount.URL}}" class="contact-account-card"> + <a href="{{.instance.ContactAccount.URL}}" class="account-card"> <img class="avatar" src="{{.instance.ContactAccount.Avatar}}" alt="" /> <h3> {{if .instance.ContactAccount.DisplayName}}{{emojify .instance.ContactAccount.Emojis (escape .instance.ContactAccount.DisplayName)}}{{else}}{{.instance.ContactAccount.Username}}{{end}}