[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
This commit is contained in:
f0x52 2023-03-29 12:18:45 +02:00 committed by GitHub
parent 6cf6613540
commit 0746ef741a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 627 additions and 312 deletions

View file

@ -101,15 +101,15 @@ $input-border: $blue1;
$input-error-border: $error3; $input-error-border: $error3;
$input-focus-border: $blue3; $input-focus-border: $blue3;
$settings-nav-bg: $bg-accent; $settings-nav-bg: $bg;
$settings-nav-header-fg: $gray1; $settings-nav-header-fg: $orange2;
$settings-nav-header-bg: $orange1;
$settings-nav-bg-hover: $gray3; $settings-nav-bg-hover: $gray3;
/* $settings-nav-fg-hover: $gray1; */ $settings-nav-fg-hover: $fg;
$settings-nav-bg-active: $gray2; $settings-nav-bg-active: $blue3;
/* $settings-nav-fg-active: $orange2; */ $settings-nav-border-active: $info-bg;
$settings-nav-fg-active: $gray2;
$error-fg: $error1; $error-fg: $error1;
$error-bg: $error2; $error-bg: $error2;

View file

@ -97,11 +97,10 @@ header {
header a { header a {
margin: 2rem; margin: 2rem;
display: flex; display: flex;
flex-direction: column;
flex-wrap: wrap; flex-wrap: wrap;
gap: 2rem;
img { img {
margin-bottom: 1rem;
align-self: center; align-self: center;
height: 6rem; height: 6rem;
} }
@ -120,7 +119,6 @@ header a {
} }
.excerpt-top { .excerpt-top {
margin-top: -1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
font-style: italic; font-style: italic;
font-weight: normal; font-weight: normal;
@ -515,41 +513,37 @@ label {
margin-bottom: 0; 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-bg;
background: $list-entry-alternate-bg;
}
h3 { &:hover {
align-self: end; background: $list-entry-alternate-bg;
margin: 0; }
color: $fg;
}
img.avatar { h3 {
border-radius: 0.5rem; align-self: end;
width: 5rem; margin: 0;
height: 5rem; color: $fg;
object-fit: cover; }
grid-row: 1 / span 2;
} img.avatar {
border-radius: 0.5rem;
width: 5rem;
height: 5rem;
object-fit: cover;
grid-row: 1 / span 2;
} }
} }

View file

@ -28,6 +28,7 @@ const { useComboBoxInput, useFileInput, useValue } = require("../../../lib/form"
const { CategorySelect } = require("../category-select"); const { CategorySelect } = require("../category-select");
const useFormSubmit = require("../../../lib/form/submit"); const useFormSubmit = require("../../../lib/form/submit");
const { useBaseUrl } = require("../../../lib/navigation/util");
const FakeToot = require("../../../components/fake-toot"); const FakeToot = require("../../../components/fake-toot");
const FormWithData = require("../../../lib/form/form-with-data"); 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 MutationButton = require("../../../components/form/mutation-button");
const { Error } = require("../../../components/error"); const { Error } = require("../../../components/error");
const base = "/settings/custom-emoji/local"; module.exports = function EmojiDetailRoute({ }) {
const baseUrl = useBaseUrl();
module.exports = function EmojiDetailRoute() { let [_match, params] = useRoute(`${baseUrl}/:emojiId`);
let [_match, params] = useRoute(`${base}/:emojiId`);
if (params?.emojiId == undefined) { if (params?.emojiId == undefined) {
return <Redirect to={base} />; return <Redirect to={baseUrl} />;
} else { } else {
return ( return (
<div className="emoji-detail"> <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} /> <FormWithData dataQuery={query.useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
</div> </div>
); );
@ -53,6 +53,7 @@ module.exports = function EmojiDetailRoute() {
}; };
function EmojiDetailForm({ data: emoji }) { function EmojiDetailForm({ data: emoji }) {
const baseUrl = useBaseUrl();
const form = { const form = {
id: useValue("id", emoji.id), id: useValue("id", emoji.id),
category: useComboBoxInput("category", { source: emoji }), category: useComboBoxInput("category", { source: emoji }),
@ -78,7 +79,7 @@ function EmojiDetailForm({ data: emoji }) {
const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation(); const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation();
if (deleteResult.isSuccess) { if (deleteResult.isSuccess) {
return <Redirect to={base} />; return <Redirect to={baseUrl} />;
} }
return ( return (

View file

@ -25,15 +25,13 @@ const { Switch, Route } = require("wouter");
const EmojiOverview = require("./overview"); const EmojiOverview = require("./overview");
const EmojiDetail = require("./detail"); const EmojiDetail = require("./detail");
const base = "/settings/custom-emoji/local"; module.exports = function CustomEmoji({ baseUrl }) {
module.exports = function CustomEmoji() {
return ( return (
<Switch> <Switch>
<Route path={`${base}/:emojiId`}> <Route path={`${baseUrl}/:emojiId`}>
<EmojiDetail baseUrl={base} /> <EmojiDetail />
</Route> </Route>
<EmojiOverview baseUrl={base} /> <EmojiOverview />
</Switch> </Switch>
); );
}; };

View file

@ -29,12 +29,13 @@ const { useTextInput } = require("../../../lib/form");
const query = require("../../../lib/query"); const query = require("../../../lib/query");
const { useEmojiByCategory } = require("../category-select"); const { useEmojiByCategory } = require("../category-select");
const { useBaseUrl } = require("../../../lib/navigation/util");
const Loading = require("../../../components/loading"); const Loading = require("../../../components/loading");
const { Error } = require("../../../components/error"); const { Error } = require("../../../components/error");
const { TextInput } = require("../../../components/form/inputs"); const { TextInput } = require("../../../components/form/inputs");
module.exports = function EmojiOverview({ baseUrl }) { module.exports = function EmojiOverview({ }) {
const { const {
data: emoji = [], data: emoji = [],
isLoading, isLoading,
@ -51,7 +52,7 @@ module.exports = function EmojiOverview({ baseUrl }) {
} else { } else {
content = ( content = (
<> <>
<EmojiList emoji={emoji} baseUrl={baseUrl} /> <EmojiList emoji={emoji} />
<NewEmojiForm 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 filterField = useTextInput("filter");
const filter = filterField.value; const filter = filterField.value;
@ -116,7 +117,7 @@ function EmojiList({ emoji, baseUrl }) {
? ( ? (
<div className="entries scrolling"> <div className="entries scrolling">
{filteredEmoji.map(([category, entries]) => { {filteredEmoji.map(([category, entries]) => {
return <EmojiCategory key={category} category={category} entries={entries} baseUrl={baseUrl} />; return <EmojiCategory key={category} category={category} entries={entries} />;
})} })}
</div> </div>
) )
@ -128,7 +129,8 @@ function EmojiList({ emoji, baseUrl }) {
); );
} }
function EmojiCategory({ category, entries, baseUrl }) { function EmojiCategory({ category, entries }) {
const baseUrl = useBaseUrl();
return ( return (
<div className="entry"> <div className="entry">
<b>{category}</b> <b>{category}</b>

View file

@ -25,6 +25,7 @@ const ParseFromToot = require("./parse-from-toot");
const query = require("../../../lib/query"); const query = require("../../../lib/query");
const Loading = require("../../../components/loading"); const Loading = require("../../../components/loading");
const { Error } = require("../../../components/error");
module.exports = function RemoteEmoji() { module.exports = function RemoteEmoji() {
// local emoji are queried for shortcode collision detection // local emoji are queried for shortcode collision detection
@ -42,7 +43,7 @@ module.exports = function RemoteEmoji() {
<> <>
<h1>Custom Emoji (remote)</h1> <h1>Custom Emoji (remote)</h1>
{error && {error &&
<div className="error accent">{error}</div> <Error error={error} />
} }
{isLoading {isLoading
? <Loading /> ? <Loading />

View file

@ -79,7 +79,7 @@ module.exports = function ImportExportForm({ form, submitParse, parseResult }) {
showError={false} showError={false}
/> />
<label className="button with-icon"> <label className="button with-icon">
<i class="fa fa-fw " aria-hidden="true" /> <i className="fa fa-fw " aria-hidden="true" />
Import file Import file
<input <input
type="file" type="file"

View file

@ -33,9 +33,7 @@ const useFormSubmit = require("../../../lib/form/submit");
const ProcessImport = require("./process"); const ProcessImport = require("./process");
const ImportExportForm = require("./form"); const ImportExportForm = require("./form");
const baseUrl = "/settings/admin/federation/import-export"; module.exports = function ImportExport({ baseUrl }) {
module.exports = function ImportExport() {
const form = { const form = {
domains: useTextInput("domains"), domains: useTextInput("domains"),
exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }) exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true })

View file

@ -22,13 +22,11 @@
const React = require("react"); const React = require("react");
const { Switch, Route } = require("wouter"); const { Switch, Route } = require("wouter");
const baseUrl = `/settings/admin/federation`;
const InstanceOverview = require("./overview"); const InstanceOverview = require("./overview");
const InstanceDetail = require("./detail"); const InstanceDetail = require("./detail");
const InstanceImportExport = require("./import-export"); const InstanceImportExport = require("./import-export");
module.exports = function Federation({ }) { module.exports = function Federation({ baseUrl }) {
return ( return (
<Switch> <Switch>
<Route path={`${baseUrl}/import-export/:list?`}> <Route path={`${baseUrl}/import-export/:list?`}>

View file

@ -34,8 +34,10 @@ const { TextArea } = require("../../components/form/inputs");
const MutationButton = require("../../components/form/mutation-button"); const MutationButton = require("../../components/form/mutation-button");
const Username = require("./username"); 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`); let [_match, params] = useRoute(`${baseUrl}/:reportId`);
if (params?.reportId == undefined) { if (params?.reportId == undefined) {
return <Redirect to={baseUrl} />; return <Redirect to={baseUrl} />;

View file

@ -28,23 +28,22 @@ const FormWithData = require("../../lib/form/form-with-data");
const ReportDetail = require("./detail"); const ReportDetail = require("./detail");
const Username = require("./username"); const Username = require("./username");
const { useBaseUrl } = require("../../lib/navigation/util");
const baseUrl = "/settings/admin/reports"; module.exports = function Reports({ baseUrl }) {
module.exports = function Reports() {
return ( return (
<div className="reports"> <div className="reports">
<Switch> <Switch>
<Route path={`${baseUrl}/:reportId`}> <Route path={`${baseUrl}/:reportId`}>
<ReportDetail baseUrl={baseUrl} /> <ReportDetail />
</Route> </Route>
<ReportOverview baseUrl={baseUrl} /> <ReportOverview />
</Switch> </Switch>
</div> </div>
); );
}; };
function ReportOverview({ _baseUrl }) { function ReportOverview({ }) {
return ( return (
<> <>
<h1>Reports</h1> <h1>Reports</h1>
@ -79,6 +78,7 @@ function ReportsList({ data: reports }) {
} }
function ReportEntry({ report }) { function ReportEntry({ report }) {
const baseUrl = useBaseUrl();
const from = report.account; const from = report.account;
const target = report.target_account; const target = report.target_account;

View file

@ -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>
);
};

View file

@ -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>
);
}
};

View file

@ -23,61 +23,59 @@ const React = require("react");
const ReactDom = require("react-dom/client"); const ReactDom = require("react-dom/client");
const { Provider } = require("react-redux"); const { Provider } = require("react-redux");
const { PersistGate } = require("redux-persist/integration/react"); const { PersistGate } = require("redux-persist/integration/react");
const { Switch, Route, Redirect } = require("wouter");
const query = require("./lib/query");
const { store, persistor } = require("./redux"); const { store, persistor } = require("./redux");
const { createNavigation, Menu, Item } = require("./lib/navigation");
const AuthorizationGate = require("./components/authorization"); const AuthorizationGate = require("./components/authorization");
const Loading = require("./components/loading"); const Loading = require("./components/loading");
const UserLogoutCard = require("./components/user-logout-card");
const { RoleContext } = require("./lib/navigation/util");
require("./style.css"); require("./style.css");
// TODO: nested categories? const { Sidebar, ViewRouter } = createNavigation("/settings", [
const nav = { Menu("User", [
"User": { Item("Profile", { icon: "fa-user" }, require("./user/profile")),
"Profile": require("./user/profile.js"), Item("Settings", { icon: "fa-cogs" }, require("./user/settings")),
"Settings": require("./user/settings.js"), ]),
}, Menu("Moderation", {
"Admin": { url: "admin",
adminOnly: true, permissions: ["admin"]
"Instance Settings": require("./admin/settings.js"), }, [
"Actions": require("./admin/actions"), Item("Reports", { icon: "fa-flag", wildcard: true }, require("./admin/reports")),
"Federation": require("./admin/federation"), Menu("Federation", { icon: "fa-hubzilla" }, [
"Reports": require("./admin/reports") 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")),
"Custom Emoji": { ])
adminOnly: true, ]),
"Local": require("./admin/emoji/local"), Menu("Administration", {
"Remote": require("./admin/emoji/remote"), url: "admin",
} defaultUrl: "/settings/admin/settings",
}; permissions: ["admin"]
}, [
const { sidebar, panelRouter } = require("./lib/get-views")(nav); 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 }) { function App({ account }) {
const isAdmin = account.role.name == "admin"; const permissions = [account.role.name];
const [logoutQuery] = query.useLogoutMutation();
return ( return (
<> <RoleContext.Provider value={permissions}>
<div className="sidebar"> <div className="sidebar">
{sidebar.all} <UserLogoutCard />
{isAdmin && sidebar.admin} <Sidebar />
<button className="logout" onClick={logoutQuery}>
Log out
</button>
</div> </div>
<section className="with-sidebar"> <section className="with-sidebar">
<Switch> <ViewRouter />
{panelRouter.all}
{isAdmin && panelRouter.admin}
<Route>
<Redirect to="/settings/user" />
</Route>
</Switch>
</section> </section>
</> </RoleContext.Provider>
); );
} }

View file

@ -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 };
};

View file

@ -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
};

View file

@ -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
};

View file

@ -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
};

View file

@ -34,18 +34,36 @@ section {
grid-column: 2; 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 { #root {
display: grid; display: grid;
/* keep in sync with base.css .page {} */ grid-template-columns: 1fr minmax(auto, 60rem) 1fr;
grid-template-columns: auto minmax(auto, 50rem) auto; grid-template-columns: 1fr min(92%, 60rem) 1fr;
grid-template-columns: auto min(92%, 50rem) auto;
box-sizing: border-box; box-sizing: border-box;
section.with-sidebar { section.with-sidebar {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
& > div, & > form { & > div, & > form {
border-left: 0.2rem solid $border-accent; border-left: 0.2rem solid $border-accent;
padding-left: 0.4rem; padding-left: 0.4rem;
@ -79,77 +97,141 @@ section {
} }
.sidebar { .sidebar {
margin: 0 1rem;
align-self: start; align-self: start;
justify-self: end; justify-self: end;
background: $settings-nav-bg; background: $bg;
border: $boxshadow-border;
box-shadow: $boxshadow;
border-radius: $br;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 12rem; min-width: 12rem;
a { .account-card {
grid-template-columns: auto 1fr auto;
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;
}
}
}
}
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; text-decoration: none;
} color: $settings-nav-header-fg;
a:first-child h2 {
border-top-left-radius: $br;
}
h2 {
margin: 0;
padding: 0.5rem; padding: 0.5rem;
font-size: 0.9rem; padding-bottom: 0;
margin: 0;
font-size: 0.8rem;
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
color: $settings-nav-header-fg;
background: $settings-nav-header-bg;
} }
nav {
display: flex;
flex-direction: column;
a { & > ul {
padding: 1rem; gap: 0.2rem;
text-decoration: none; }
transition: 0.1s; }
color: $fg;
li.expanding { /* second-level categories, expanding box, active shows nested */
&:hover { a {
color: $settings-nav-fg-hover; display: block;
background: $settings-nav-bg-hover; color: $fg;
} text-decoration: none;
&.active { border: 0.1rem solid transparent;
color: $settings-nav-fg-active; border-radius: $br;
background: $settings-nav-bg-active; padding: 0.5rem;
font-weight: bold; transition: background 0.1s;
text-decoration: underline;
} &:hover {
color: $settings-nav-fg-hover;
/* reserve space for bold version of the element, so .active doesn't background: $settings-nav-bg-hover;
change container size */ }
&::after {
font-weight: bold; &:focus, &:active {
text-decoration: underline; border-color: $settings-nav-border-active;
display: block; outline: none;
content: attr(data-content);
height: 1px;
color: transparent;
overflow: hidden;
visibility: hidden;
}
} }
} }
&.active {
border: 0.1rem solid $settings-nav-border-active;
border-radius: $br;
overflow: hidden;
nav:last-child a:last-child { a {
border-bottom-left-radius: $br; transition: background 0s;
border-bottom: none; 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;
&:focus {
color: $fg-accent;
outline: none;
}
&:hover {
background: $settings-nav-bg-hover;
}
}
&.active {
a.title {
color: $fg-accent;
font-weight: bold;
}
} }
} }
} }

View file

@ -27,7 +27,7 @@
<div> <div>
<h2>Admin Contact</h2> <h2>Admin Contact</h2>
{{if .instance.ContactAccount}} {{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="" /> <img class="avatar" src="{{.instance.ContactAccount.Avatar}}" alt="" />
<h3> <h3>
{{if .instance.ContactAccount.DisplayName}}{{emojify .instance.ContactAccount.Emojis (escape .instance.ContactAccount.DisplayName)}}{{else}}{{.instance.ContactAccount.Username}}{{end}} {{if .instance.ContactAccount.DisplayName}}{{emojify .instance.ContactAccount.Emojis (escape .instance.ContactAccount.DisplayName)}}{{else}}{{.instance.ContactAccount.Username}}{{end}}