diff options
Diffstat (limited to 'web')
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>< 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 ( 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}} | 
