summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--web/source/css/_colors.css12
-rw-r--r--web/source/css/base.css62
-rw-r--r--web/source/settings/admin/emoji/local/detail.js15
-rw-r--r--web/source/settings/admin/emoji/local/index.js10
-rw-r--r--web/source/settings/admin/emoji/local/overview.js12
-rw-r--r--web/source/settings/admin/emoji/remote/index.js3
-rw-r--r--web/source/settings/admin/federation/import-export/form.jsx2
-rw-r--r--web/source/settings/admin/federation/import-export/index.jsx4
-rw-r--r--web/source/settings/admin/federation/index.js4
-rw-r--r--web/source/settings/admin/reports/detail.jsx4
-rw-r--r--web/source/settings/admin/reports/index.jsx12
-rw-r--r--web/source/settings/components/nav-button.jsx34
-rw-r--r--web/source/settings/components/user-logout-card.jsx47
-rw-r--r--web/source/settings/index.js78
-rw-r--r--web/source/settings/lib/get-views.js102
-rw-r--r--web/source/settings/lib/navigation/components.jsx141
-rw-r--r--web/source/settings/lib/navigation/index.js138
-rw-r--r--web/source/settings/lib/navigation/util.js51
-rw-r--r--web/source/settings/style.css200
-rw-r--r--web/template/about.tmpl2
20 files changed, 624 insertions, 309 deletions
diff --git a/web/source/css/_colors.css b/web/source/css/_colors.css
index 6d4b056b8..01f59a55d 100644
--- 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
index c30b72373..45b797c64 100644
--- 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
index 101652686..6b583b0b9 100644
--- 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
index 3c88f49c3..56695094c 100644
--- 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
index 616c5144b..757729c38 100644
--- 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
index 757e443a4..bf7113b49 100644
--- 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
index 1cb8d2d74..a6967b8f0 100644
--- 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
index 415698bb3..7126b4e89 100644
--- 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
index f4d0a01d2..081d34113 100644
--- 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
index abec6210e..0fd7802bc 100644
--- 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
index 0bb875d86..b2b8b4295 100644
--- 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
deleted file mode 100644
index a63382c1a..000000000
--- a/web/source/settings/components/nav-button.jsx
+++ /dev/null
@@ -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
new file mode 100644
index 000000000..902d545bc
--- /dev/null
+++ 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
index 87179446d..66848291b 100644
--- 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
deleted file mode 100644
index 23f517e27..000000000
--- a/web/source/settings/lib/get-views.js
+++ /dev/null
@@ -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
new file mode 100644
index 000000000..18e0cd76c
--- /dev/null
+++ 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
new file mode 100644
index 000000000..c1331d0ec
--- /dev/null
+++ 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
new file mode 100644
index 000000000..f0b29d7a1
--- /dev/null
+++ 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
index c2ebd6c01..07a4d05e2 100644
--- 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
index 48d7d2174..0f69cf59e 100644
--- 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}}