[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:
parent
6cf6613540
commit
0746ef741a
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
@ -551,7 +546,6 @@ label {
|
|||
grid-row: 1 / span 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 30rem) {
|
||||
.domain-blocklist .entry {
|
||||
|
|
|
@ -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>< go back</a></Link>
|
||||
<Link to={baseUrl}><a>< 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 (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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?`}>
|
||||
|
|
|
@ -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} />;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 };
|
||||
};
|
|
@ -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
|
||||
};
|
|
@ -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
|
||||
};
|
|
@ -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
|
||||
};
|
|
@ -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 {
|
||||
|
|
|
@ -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}}
|
||||
|
|
Loading…
Reference in New Issue