mirror of
https://github.com/docmost/docmost.git
synced 2025-11-22 10:51:08 +10:00
refactor layout
* ui polishing * frontend and backend fixes
This commit is contained in:
@ -1,6 +0,0 @@
|
||||
.breadcrumb {
|
||||
a {
|
||||
color: var(--mantine-color-default-color);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
||||
import {
|
||||
Button,
|
||||
Anchor,
|
||||
Popover,
|
||||
Breadcrumbs,
|
||||
ActionIcon,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./breadcrumb.module.css";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { buildPageSlug } from "@/features/page/page.utils.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
|
||||
function getTitle(name: string, icon: string) {
|
||||
if (icon) {
|
||||
return `${icon} ${name}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export default function Breadcrumb() {
|
||||
const treeData = useAtomValue(treeDataAtom);
|
||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
||||
SpaceTreeNode[] | null
|
||||
>(null);
|
||||
const { slugId } = useParams();
|
||||
const { data: currentPage } = usePageQuery(slugId);
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData?.length > 0 && currentPage) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
|
||||
if (breadcrumb) {
|
||||
setBreadcrumbNodes(breadcrumb);
|
||||
}
|
||||
}
|
||||
}, [currentPage?.id, treeData]);
|
||||
|
||||
const HiddenNodesTooltipContent = () =>
|
||||
breadcrumbNodes?.slice(1, -2).map((node) => (
|
||||
<Button.Group orientation="vertical" key={node.id}>
|
||||
<Button
|
||||
justify="start"
|
||||
component={Link}
|
||||
to={buildPageSlug(node.slugId, node.name)}
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<Text truncate="end">{getTitle(node.name, node.icon)}</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
));
|
||||
|
||||
const getLastNthNode = (n: number) =>
|
||||
breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
|
||||
|
||||
const getBreadcrumbItems = () => {
|
||||
if (breadcrumbNodes?.length > 3) {
|
||||
return [
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={buildPageSlug(breadcrumbNodes[0].slugId, breadcrumbNodes[0].name)}
|
||||
underline="never"
|
||||
key={breadcrumbNodes[0].slugId}
|
||||
>
|
||||
{getTitle(breadcrumbNodes[0].name, breadcrumbNodes[0].icon)}
|
||||
</Anchor>,
|
||||
<Popover
|
||||
width={250}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="xl"
|
||||
key="hidden-nodes"
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon c="gray" variant="transparent">
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<HiddenNodesTooltipContent />
|
||||
</Popover.Dropdown>
|
||||
</Popover>,
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={buildPageSlug(getLastNthNode(2)?.slugId, getLastNthNode(2)?.name)}
|
||||
underline="never"
|
||||
key={getLastNthNode(2)?.slugId}
|
||||
>
|
||||
{getTitle(getLastNthNode(2)?.name, getLastNthNode(2)?.icon)}
|
||||
</Anchor>,
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={buildPageSlug(getLastNthNode(1)?.slugId, getLastNthNode(1)?.name)}
|
||||
underline="never"
|
||||
key={getLastNthNode(1)?.slugId}
|
||||
>
|
||||
{getTitle(getLastNthNode(1)?.name, getLastNthNode(1)?.icon)}
|
||||
</Anchor>,
|
||||
];
|
||||
}
|
||||
|
||||
if (breadcrumbNodes) {
|
||||
return breadcrumbNodes.map((node) => (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={buildPageSlug(node.slugId, node.name)}
|
||||
underline="never"
|
||||
key={node.id}
|
||||
>
|
||||
{getTitle(node.name, node.icon)}
|
||||
</Anchor>
|
||||
));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.breadcrumb}>
|
||||
{breadcrumbNodes ? (
|
||||
<Breadcrumbs>{getBreadcrumbItems()}</Breadcrumbs>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import Shell from "./shell.tsx";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
export default function DashboardLayout() {
|
||||
return (
|
||||
<UserProvider>
|
||||
<Shell>
|
||||
<Helmet>
|
||||
<title>Home</title>
|
||||
</Helmet>
|
||||
<Outlet />
|
||||
</Shell>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconDots,
|
||||
IconHistory,
|
||||
IconLink,
|
||||
IconMessage,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageSlug } from "@/features/page/page.utils.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
export default function Header() {
|
||||
const toggleAside = useToggleAside();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
<Button variant="default" style={{ border: "none" }} size="compact-sm">
|
||||
Share
|
||||
</Button>
|
||||
*/}
|
||||
|
||||
<Tooltip label="Comments" openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
onClick={() => toggleAside("comments")}
|
||||
>
|
||||
<IconMessage size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<PageActionMenu />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PageActionMenu() {
|
||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { slugId } = useParams();
|
||||
const { data: page, isLoading, isError } = usePageQuery(slugId);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageLink =
|
||||
window.location.host + buildPageSlug(page.slugId, page.title);
|
||||
clipboard.copy(pageLink);
|
||||
notifications.show({ message: "Link copied" });
|
||||
};
|
||||
|
||||
const openHistoryModal = () => {
|
||||
setHistoryModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} stroke={2} />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} stroke={2} />}
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
Page history
|
||||
</Menu.Item>
|
||||
|
||||
{/*
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconTrash size={16} stroke={2} />}>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
*/}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
.header,
|
||||
.footer {
|
||||
@media (max-width: 992px) {
|
||||
[data-layout="alt"] & {
|
||||
--_section-right: var(--app-shell-aside-offset, 0px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aside {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
|
||||
@media (min-width: 993px) {
|
||||
[data-layout="alt"] & {
|
||||
--_section-top: var(--_section-top, var(--app-shell-header-offset, 0px));
|
||||
--_section-height: var(
|
||||
--_section-height,
|
||||
calc(
|
||||
100dvh - var(--app-shell-header-offset, 0px) -
|
||||
var(--app-shell-footer-offset, 0px)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.aside {
|
||||
width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.navbar {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,88 +0,0 @@
|
||||
import {
|
||||
asideStateAtom,
|
||||
desktopSidebarAtom,
|
||||
} from "@/components/navbar/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/navbar/hooks/use-toggle-sidebar.ts";
|
||||
import { Navbar } from "@/components/navbar/navbar.tsx";
|
||||
import { AppShell, Burger, Group } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import classes from "./shell.module.css";
|
||||
import Header from "@/components/layouts/dashboard/header.tsx";
|
||||
import Breadcrumb from "@/components/layouts/components/breadcrumb.tsx";
|
||||
import Aside from "@/components/layouts/dashboard/aside.tsx";
|
||||
import { useMatchPath } from "@/hooks/use-match-path.tsx";
|
||||
import React from "react";
|
||||
|
||||
export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] =
|
||||
useDisclosure();
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
const matchPath = useMatchPath();
|
||||
const isPageRoute = matchPath("/p/:pageId");
|
||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
layout="alt"
|
||||
header={{ height: 45 }}
|
||||
navbar={{
|
||||
width: 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||
}}
|
||||
aside={{
|
||||
width: 350,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header className={classes.header} withBorder={false}>
|
||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||
<Group
|
||||
h="100%"
|
||||
maw="60%"
|
||||
px="md"
|
||||
wrap="nowrap"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Burger
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<Burger
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{isPageRoute && <Breadcrumb />}
|
||||
</Group>
|
||||
|
||||
{isPageRoute && (
|
||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
||||
<Header />
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar className={classes.navbar} withBorder={false}>
|
||||
<Navbar />
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
|
||||
{isPageRoute && (
|
||||
<AppShell.Aside className={classes.aside} withBorder={false}>
|
||||
<Aside />
|
||||
</AppShell.Aside>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
.header {
|
||||
height: 100%;
|
||||
margin-bottom: rem(120px);
|
||||
background-color: var(--mantine-color-body);
|
||||
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
padding-left: var(--mantine-spacing-md);
|
||||
padding-right: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
padding: rem(8px) rem(12px);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
text-decoration: none;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
}
|
||||
70
apps/client/src/components/layouts/global/app-header.tsx
Normal file
70
apps/client/src/components/layouts/global/app-header.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { Group } from "@mantine/core";
|
||||
import { IconSquareLetterDFilled } from "@tabler/icons-react";
|
||||
import classes from "./app-header.module.css";
|
||||
import React from "react";
|
||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useAtom } from "jotai/index";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||
|
||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||
|
||||
export function AppHeader() {
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
|
||||
const items = links.map((link) => (
|
||||
<Link key={link.label} to={link.link} className={classes.link}>
|
||||
{link.label}
|
||||
</Link>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||
<Group>
|
||||
<IconSquareLetterDFilled size={30} />
|
||||
|
||||
{!isHomeRoute && (
|
||||
<>
|
||||
<SidebarToggle
|
||||
aria-label="sidebar toggle"
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<SidebarToggle
|
||||
aria-label="sidebar toggle"
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
|
||||
{items}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group px={"xl"}>
|
||||
<TopMenu />
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
.header, .navbar, .aside {
|
||||
background-color: light-dark(#f6f7f9, var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.navbar, .aside {
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
.aside {
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
margin-top: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Box, ScrollArea, Text } from "@mantine/core";
|
||||
import CommentList from "@/features/comment/components/comment-list.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { asideStateAtom } from "@/components/navbar/atoms/sidebar-atom.ts";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
export default function Aside() {
|
||||
@ -0,0 +1,77 @@
|
||||
import { AppShell, Container } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
asideStateAtom,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||
import Aside from "@/components/layouts/global/aside.tsx";
|
||||
import classes from "./app-shell.module.css";
|
||||
|
||||
export default function GlobalAppShell({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||
|
||||
const location = useLocation();
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 45 }}
|
||||
navbar={
|
||||
!isHomeRoute && {
|
||||
width: 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
},
|
||||
}
|
||||
}
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 350,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||
}
|
||||
}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header px="md" className={classes.header}>
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
{!isHomeRoute && (
|
||||
<AppShell.Navbar className={classes.navbar} withBorder={false}>
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
<AppShell.Main>
|
||||
{isSettingsRoute ? (
|
||||
<Container size={800}>{children}</Container>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AppShell.Main>
|
||||
|
||||
{isPageRoute && (
|
||||
<AppShell.Aside className={classes.aside} p="md" withBorder={false}>
|
||||
<Aside />
|
||||
</AppShell.Aside>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const mobileSidebarAtom = atom<boolean>(false);
|
||||
|
||||
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
||||
"showSidebar",
|
||||
true,
|
||||
);
|
||||
|
||||
export const desktopAsideAtom = atom<boolean>(false);
|
||||
|
||||
type AsideStateType = {
|
||||
tab: string;
|
||||
isAsideOpen: boolean;
|
||||
};
|
||||
|
||||
export const asideStateAtom = atom<AsideStateType>({
|
||||
tab: "",
|
||||
isAsideOpen: false,
|
||||
});
|
||||
@ -0,0 +1,8 @@
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
export function useToggleSidebar(sidebarAtom: any) {
|
||||
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
|
||||
return () => {
|
||||
setSidebarState(!sidebarState);
|
||||
}
|
||||
}
|
||||
13
apps/client/src/components/layouts/global/layout.tsx
Normal file
13
apps/client/src/components/layouts/global/layout.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<UserProvider>
|
||||
<GlobalAppShell>
|
||||
<Outlet />
|
||||
</GlobalAppShell>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
124
apps/client/src/components/layouts/global/top-menu.tsx
Normal file
124
apps/client/src/components/layouts/global/top-menu.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { Avatar, Group, Menu, rem, UnstyledButton, Text } from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
|
||||
|
||||
export default function TopMenu() {
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const { logout } = useAuth();
|
||||
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
|
||||
return (
|
||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap={7} wrap={"nowrap"}>
|
||||
<Avatar
|
||||
src={workspace.logo}
|
||||
alt={workspace.name}
|
||||
radius="xl"
|
||||
size={20}
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3}>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
<IconChevronDown
|
||||
style={{ width: rem(12), height: rem(12) }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Workspace</Menu.Label>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
||||
leftSection={
|
||||
<IconSettings
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Workspace settings
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
||||
leftSection={
|
||||
<IconUsers
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Manage members
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Label>Account</Menu.Label>
|
||||
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
||||
<Group wrap={"nowrap"}>
|
||||
<UserAvatar
|
||||
radius="xl"
|
||||
size={"sm"}
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
||||
leftSection={
|
||||
<IconUserCircle
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
}
|
||||
>
|
||||
My profile
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
onClick={logout}
|
||||
leftSection={
|
||||
<IconLogout
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import SettingsShell from "@/components/layouts/settings/settings-shell.tsx";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
export default function SettingsLayout() {
|
||||
return (
|
||||
<UserProvider>
|
||||
<SettingsShell>
|
||||
<Helmet>
|
||||
<title>Settings</title>
|
||||
</Helmet>
|
||||
<Outlet />
|
||||
</SettingsShell>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
import { desktopSidebarAtom } from "@/components/navbar/atoms/sidebar-atom.ts";
|
||||
import { AppShell, Container } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import SettingsSidebar from "@/components/layouts/settings/settings-sidebar.tsx";
|
||||
|
||||
export default function SettingsShell({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
layout="alt"
|
||||
header={{ height: 45 }}
|
||||
navbar={{
|
||||
width: 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Navbar>
|
||||
<SettingsSidebar />
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Container size={800}>{children}</Container>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Group, Text, ScrollArea, ActionIcon, rem } from "@mantine/core";
|
||||
import {
|
||||
IconUser,
|
||||
IconSettings,
|
||||
IconUsers,
|
||||
IconArrowLeft,
|
||||
IconUsersGroup,
|
||||
IconSpaces,
|
||||
IconBrush,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
|
||||
interface DataItem {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface DataGroup {
|
||||
heading: string;
|
||||
items: DataItem[];
|
||||
}
|
||||
|
||||
const groupedData: DataGroup[] = [
|
||||
{
|
||||
heading: "Account",
|
||||
items: [
|
||||
{ label: "Profile", icon: IconUser, path: "/settings/account/profile" },
|
||||
{
|
||||
label: "Preferences",
|
||||
icon: IconBrush,
|
||||
path: "/settings/account/preferences",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Workspace",
|
||||
items: [
|
||||
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
|
||||
{
|
||||
label: "Members",
|
||||
icon: IconUsers,
|
||||
path: "/settings/members",
|
||||
},
|
||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsSidebar() {
|
||||
const pathname = useLocation().pathname;
|
||||
const [active, setActive] = useState(pathname);
|
||||
|
||||
const menuItems = groupedData.map((group) => (
|
||||
<div key={group.heading}>
|
||||
<Text c="dimmed" className={classes.linkHeader}>
|
||||
{group.heading}
|
||||
</Text>
|
||||
{group.items.map((item) => (
|
||||
<Link
|
||||
className={classes.link}
|
||||
data-active={active.startsWith(item.path) || undefined}
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
onClick={() => {
|
||||
setActive(item.path);
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<nav className={classes.navbar}>
|
||||
<div>
|
||||
<Group className={classes.header} justify="flex-start">
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to="/home"
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
aria-label="Home"
|
||||
>
|
||||
<IconArrowLeft stroke={2} />
|
||||
</ActionIcon>
|
||||
<Text fw={500}>Settings</Text>
|
||||
</Group>
|
||||
|
||||
<ScrollArea h="80vh" w="100%">
|
||||
{menuItems}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className={classes.footer}>
|
||||
<Link to="/home" className={classes.link}>
|
||||
<IconArrowLeft className={classes.linkIcon} stroke={1.5} />
|
||||
<span>Return to the app</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Divider, Title } from '@mantine/core';
|
||||
|
||||
export default function SettingsTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Title order={3}>
|
||||
{title}
|
||||
</Title>
|
||||
<Divider my="md" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
.navbar {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/*border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));*/
|
||||
}
|
||||
|
||||
.navbarMain {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding-bottom: var(--mantine-spacing-md);
|
||||
margin-bottom: calc(var(--mantine-spacing-md) * 1.5);
|
||||
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding-top: var(--mantine-spacing-md);
|
||||
margin-top: var(--mantine-spacing-md);
|
||||
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.link {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding-left: var(--mantine-spacing-xs) ;
|
||||
min-height: 30px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
&,
|
||||
& :hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
margin-right: var(--mantine-spacing-sm);
|
||||
width: rem(16px);
|
||||
height: rem(16px);
|
||||
}
|
||||
|
||||
.linkHeader {
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
Reference in New Issue
Block a user