[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-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;

View file

@ -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,12 +513,9 @@ label {
margin-bottom: 0;
}
.contact-account-card {
/* display: inline-grid;
grid-template-columns: 4rem auto;
grid-template-rows: 4rem;
gap: 1rem;
padding: 0.5rem; */
}
.account-card {
display: inline-grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
@ -550,7 +545,6 @@ label {
object-fit: cover;
grid-row: 1 / span 2;
}
}
}
@media screen and (max-width: 30rem) {

View file

@ -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 (

View file

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

View file

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

View file

@ -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 />

View file

@ -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"

View file

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

View file

@ -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?`}>

View file

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

View file

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

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

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;
}
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,79 +97,143 @@ 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;
img.avatar {
width: 4rem;
height: 4rem;
}
a:first-child h2 {
border-top-left-radius: $br;
span {
grid-row: 2;
}
h2 {
margin: 0;
padding: 0.5rem;
font-size: 0.9rem;
font-weight: bold;
text-transform: uppercase;
color: $settings-nav-header-fg;
background: $settings-nav-header-bg;
.logout {
font-size: 1.5rem;
align-self: center;
grid-row: 1 / span 2;
}
nav {
&:hover {
background: $list-entry-bg;
}
}
}
}
nav.menu-tree {
ul {
display: flex;
flex-direction: column;
list-style-type: none;
margin: 0;
padding: 0;
}
a {
padding: 1rem;
.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;
transition: 0.1s;
color: $settings-nav-header-fg;
padding: 0.5rem;
padding-bottom: 0;
margin: 0;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
}
& > 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 {
transition: background 0s;
border: none;
color: $settings-nav-fg-active;
background: $settings-nav-bg-active;
font-weight: bold;
text-decoration: underline;
border-radius: 0;
}
}
}
/* reserve space for bold version of the element, so .active doesn't
change container size */
&::after {
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;
text-decoration: underline;
display: block;
content: attr(data-content);
height: 1px;
color: transparent;
overflow: hidden;
visibility: hidden;
}
}
}
nav:last-child a:last-child {
border-bottom-left-radius: $br;
border-bottom: none;
}
}
}
.capitalize {

View file

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