client: updates

* work on groups ui
* move settings to its own page
* other fixes and refactoring
This commit is contained in:
Philipinho
2024-04-04 22:19:15 +01:00
parent cab5e67055
commit 1412f1d982
64 changed files with 1770 additions and 474 deletions

View File

@ -1,25 +1,39 @@
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from "react-router-dom";
import { Welcome } from '@/pages/welcome'; import { Welcome } from "@/pages/welcome";
import SignUpPage from '@/pages/auth/signup'; import SignUpPage from "@/pages/auth/signup";
import LoginPage from '@/pages/auth/login'; import LoginPage from "@/pages/auth/login";
import DashboardLayout from '@/components/layouts/layout'; import DashboardLayout from "@/components/layouts/dashboard/dashboard-layout.tsx";
import Home from '@/pages/dashboard/home'; import Home from "@/pages/dashboard/home";
import Page from '@/pages/page/page'; import Page from "@/pages/page/page";
import AccountSettings from "@/pages/settings/account/account-settings";
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
import SettingsLayout from "@/components/layouts/settings/settings-layout.tsx";
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
import Groups from "@/pages/settings/group/groups";
import GroupInfo from "./pages/settings/group/group-info";
export default function App() { export default function App() {
return ( return (
<> <>
<Routes> <Routes>
<Route index element={<Welcome />} /> <Route index element={<Welcome />} />
<Route path={'/login'} element={<LoginPage />} /> <Route path={"/login"} element={<LoginPage />} />
<Route path={'/signup'} element={<SignUpPage />} /> <Route path={"/signup"} element={<SignUpPage />} />
<Route element={<DashboardLayout />}> <Route element={<DashboardLayout />}>
<Route path={'/home'} element={<Home />} /> <Route path={"/home"} element={<Home />} />
<Route path={'/p/:pageId'} element={<Page />} /> <Route path={"/p/:pageId"} element={<Page />} />
</Route> </Route>
<Route path={"/settings"} element={<SettingsLayout />}>
<Route path={"profile"} element={<AccountSettings />} />
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Home />} />
<Route path={"security"} element={<Home />} />
</Route>
</Routes> </Routes>
</> </>
); );

View File

@ -66,7 +66,7 @@ export default function Breadcrumb() {
</Anchor>, </Anchor>,
<Popover width={250} position="bottom" withArrow shadow="xl" key="hidden-nodes"> <Popover width={250} position="bottom" withArrow shadow="xl" key="hidden-nodes">
<Popover.Target> <Popover.Target>
<ActionIcon color="gray" variant="transparent"> <ActionIcon c="gray" variant="transparent">
<IconDots size={20} stroke={2} /> <IconDots size={20} stroke={2} />
</ActionIcon> </ActionIcon>
</Popover.Target> </Popover.Target>

View File

@ -0,0 +1,42 @@
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 React from "react";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
let title;
let component;
switch (tab) {
case "comments":
component = <CommentList />;
title = "Comments";
break;
default:
component = null;
title = null;
}
return (
<Box p="md">
{component && (
<>
<Text mb="md" fw={500}>
{title}
</Text>
<ScrollArea
style={{ height: "85vh" }}
scrollbarSize={5}
type="scroll"
>
<div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea>
</>
)}
</Box>
);
}

View File

@ -1,39 +0,0 @@
import { Box, ScrollArea, Text } from '@mantine/core';
import CommentList from '@/features/comment/components/comment-list';
import { useAtom } from 'jotai';
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
import React from 'react';
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
let title;
let component;
switch (tab) {
case 'comments':
component = <CommentList />;
title = 'Comments';
break;
default:
component = null;
title = null;
}
return (
<Box p="md">
{component && (
<>
<Text mb="md" fw={500}>{title}</Text>
<ScrollArea style={{ height: '85vh' }} scrollbarSize={5} type="scroll">
<div style={{ paddingBottom: '200px' }}>
{component}
</div>
</ScrollArea>
</>
)}
</Box>
);
}

View File

@ -1,9 +1,8 @@
import { UserProvider } from '@/features/user/user-provider'; import { UserProvider } from "@/features/user/user-provider.tsx";
import Shell from './shell'; import Shell from "./shell.tsx";
import { Outlet } from 'react-router-dom'; import { Outlet } from "react-router-dom";
export default function DashboardLayout() { export default function DashboardLayout() {
return ( return (
<UserProvider> <UserProvider>
<Shell> <Shell>

View File

@ -1,8 +1,4 @@
import { import { ActionIcon, Menu, Button } from "@mantine/core";
ActionIcon,
Menu,
Button,
} from '@mantine/core';
import { import {
IconDots, IconDots,
IconFileInfo, IconFileInfo,
@ -12,22 +8,26 @@ import {
IconShare, IconShare,
IconTrash, IconTrash,
IconMessage, IconMessage,
} from '@tabler/icons-react'; } from "@tabler/icons-react";
import React from 'react'; import React from "react";
import useToggleAside from '@/hooks/use-toggle-aside'; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { historyAtoms } from '@/features/page-history/atoms/history-atoms'; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
export default function Header() { export default function Header() {
const toggleAside = useToggleAside(); const toggleAside = useToggleAside();
return ( return (
<> <>
<Button variant="default" style={{ border: 'none' }} size="compact-sm"> <Button variant="default" style={{ border: "none" }} size="compact-sm">
Share Share
</Button> </Button>
<ActionIcon variant="default" style={{ border: 'none' }} onClick={() => toggleAside('comments')}> <ActionIcon
variant="default"
style={{ border: "none" }}
onClick={() => toggleAside("comments")}
>
<IconMessage size={20} stroke={2} /> <IconMessage size={20} stroke={2} />
</ActionIcon> </ActionIcon>
@ -53,38 +53,33 @@ function PageActionMenu() {
arrowPosition="center" arrowPosition="center"
> >
<Menu.Target> <Menu.Target>
<ActionIcon variant="default" style={{ border: 'none' }}> <ActionIcon variant="default" style={{ border: "none" }}>
<IconDots size={20} stroke={2} /> <IconDots size={20} stroke={2} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item leftSection={<IconFileInfo size={16} stroke={2} />}>
leftSection={<IconFileInfo size={16} stroke={2} />}>
Page info Page info
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item leftSection={<IconLink size={16} stroke={2} />}>
leftSection={<IconLink size={16} stroke={2} />}
>
Copy link Copy link
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item leftSection={<IconShare size={16} stroke={2} />}>
leftSection={<IconShare size={16} stroke={2} />}>
Share Share
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
leftSection={<IconHistory size={16} stroke={2} />} leftSection={<IconHistory size={16} stroke={2} />}
onClick={openHistoryModal}> onClick={openHistoryModal}
>
Page history Page history
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item leftSection={<IconLock size={16} stroke={2} />}>
leftSection={<IconLock size={16} stroke={2} />}>
Lock Lock
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item leftSection={<IconTrash size={16} stroke={2} />}>
leftSection={<IconTrash size={16} stroke={2} />}>
Delete Delete
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>

View File

@ -1,22 +1,25 @@
import { asideStateAtom, desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom'; import {
import { useToggleSidebar } from '@/components/navbar/hooks/use-toggle-sidebar'; asideStateAtom,
import { Navbar } from '@/components/navbar/navbar'; desktopSidebarAtom,
import { AppShell, Burger, Group } from '@mantine/core'; } from "@/components/navbar/atoms/sidebar-atom.ts";
import { useDisclosure } from '@mantine/hooks'; import { useToggleSidebar } from "@/components/navbar/hooks/use-toggle-sidebar.ts";
import { useAtom } from 'jotai'; import { Navbar } from "@/components/navbar/navbar.tsx";
import classes from './shell.module.css'; import { AppShell, Burger, Group } from "@mantine/core";
import Header from '@/components/layouts/header'; import { useDisclosure } from "@mantine/hooks";
import Breadcrumb from '@/components/layouts/components/breadcrumb'; import { useAtom } from "jotai";
import Aside from '@/components/aside/aside'; import classes from "./shell.module.css";
import { useMatchPath } from '@/hooks/use-match-path'; import Header from "@/components/layouts/dashboard/header.tsx";
import React from 'react'; 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 }) { export default function Shell({ children }: { children: React.ReactNode }) {
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure(); const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
const [desktopOpened] = useAtom(desktopSidebarAtom); const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom); const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const matchPath = useMatchPath(); const matchPath = useMatchPath();
const isPageRoute = matchPath('/p/:pageId'); const isPageRoute = matchPath("/p/:pageId");
const [{ isAsideOpen }] = useAtom(asideStateAtom); const [{ isAsideOpen }] = useAtom(asideStateAtom);
return ( return (
@ -25,23 +28,25 @@ export default function Shell({ children }: { children: React.ReactNode }) {
header={{ height: 45 }} header={{ height: 45 }}
navbar={{ navbar={{
width: 300, width: 300,
breakpoint: 'sm', breakpoint: "sm",
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened }, collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}} }}
aside={{ aside={{
width: 300, width: 300,
breakpoint: 'md', breakpoint: "md",
collapsed: { mobile: (!isAsideOpen), desktop: (!isAsideOpen) }, collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
}} }}
padding="md" padding="md"
> >
<AppShell.Header <AppShell.Header className={classes.header}>
className={classes.header}
>
<Group justify="space-between" h="100%" px="md" wrap="nowrap"> <Group justify="space-between" h="100%" px="md" wrap="nowrap">
<Group
<Group h="100%" maw="60%" px="md" wrap="nowrap" style={{ overflow: 'hidden' }}> h="100%"
maw="60%"
px="md"
wrap="nowrap"
style={{ overflow: "hidden" }}
>
<Burger <Burger
opened={mobileOpened} opened={mobileOpened}
onClick={toggleMobile} onClick={toggleMobile}
@ -58,31 +63,25 @@ export default function Shell({ children }: { children: React.ReactNode }) {
{isPageRoute && <Breadcrumb />} {isPageRoute && <Breadcrumb />}
</Group> </Group>
{ {isPageRoute && (
isPageRoute &&
<Group justify="flex-end" h="100%" px="md" wrap="nowrap"> <Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<Header /> <Header />
</Group> </Group>
} )}
</Group> </Group>
</AppShell.Header> </AppShell.Header>
<AppShell.Navbar> <AppShell.Navbar>
<Navbar /> <Navbar />
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main> <AppShell.Main>{children}</AppShell.Main>
{children}
</AppShell.Main>
{ {isPageRoute && (
isPageRoute &&
<AppShell.Aside className={classes.aside}> <AppShell.Aside className={classes.aside}>
<Aside /> <Aside />
</AppShell.Aside> </AppShell.Aside>
} )}
</AppShell> </AppShell>
); );
} }

View File

@ -0,0 +1,13 @@
import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom";
import SettingsShell from "@/components/layouts/settings/settings-shell.tsx";
export default function SettingsLayout() {
return (
<UserProvider>
<SettingsShell>
<Outlet />
</SettingsShell>
</UserProvider>
);
}

View File

@ -0,0 +1,36 @@
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>
);
}

View File

@ -0,0 +1,108 @@
import React, { useState } from "react";
import { Group, Text, ScrollArea, ActionIcon, rem } from "@mantine/core";
import {
IconFingerprint,
IconUser,
IconSettings,
IconUsers,
IconArrowLeft,
IconUsersGroup,
IconSpaces,
} from "@tabler/icons-react";
import { Link } 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/profile" },
{ label: "Preferences", icon: IconUser, path: "/settings/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" },
{
label: "Security",
icon: IconFingerprint,
path: "/settings/security",
},
],
},
];
export default function SettingsSidebar() {
const [active, setActive] = useState("Profile");
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={item.label === active || undefined}
key={item.label}
to={item.path}
onClick={() => {
setActive(item.label);
}}
>
<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>
);
}

View File

@ -0,0 +1,13 @@
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" />
</>
);
}

View File

@ -0,0 +1,71 @@
.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: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
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;
}

View File

@ -12,15 +12,13 @@ import {
IconPlus, IconPlus,
IconSettings, IconSettings,
IconFilePlus, IconFilePlus,
IconHome IconHome,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import classes from './navbar.module.css'; import classes from './navbar.module.css';
import { UserButton } from './user-button'; import { UserButton } from './user-button';
import React from 'react'; import React from 'react';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { settingsModalAtom } from '@/features/settings/modal/atoms/settings-modal-atom';
import SettingsModal from '@/features/settings/modal/settings-modal';
import { SearchSpotlight } from '@/features/search/search-spotlight'; import { SearchSpotlight } from '@/features/search/search-spotlight';
import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom'; import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom';
import PageTree from '@/features/page/tree/page-tree'; import PageTree from '@/features/page/tree/page-tree';
@ -36,11 +34,10 @@ const primaryMenu: PrimaryMenuItem[] = [
{ icon: IconHome, label: 'Home' }, { icon: IconHome, label: 'Home' },
{ icon: IconSearch, label: 'Search' }, { icon: IconSearch, label: 'Search' },
{ icon: IconSettings, label: 'Settings' }, { icon: IconSettings, label: 'Settings' },
// { icon: IconFilePlus, label: 'New Page' }, // { icon: IconFilePlus, label: 'New Page' },
]; ];
export function Navbar() { export function Navbar() {
const [, setSettingsModalOpen] = useAtom(settingsModalAtom);
const [tree] = useAtom(treeApiAtom); const [tree] = useAtom(treeApiAtom);
const navigate = useNavigate(); const navigate = useNavigate();
@ -54,7 +51,7 @@ export function Navbar() {
} }
if (label === 'Settings') { if (label === 'Settings') {
setSettingsModalOpen(true); navigate('/settings/workspace');
} }
}; };
@ -69,11 +66,7 @@ export function Navbar() {
onClick={() => handleMenuItemClick(menuItem.label)} onClick={() => handleMenuItemClick(menuItem.label)}
> >
<div className={classes.menuItemInner}> <div className={classes.menuItemInner}>
<menuItem.icon <menuItem.icon size={18} className={classes.menuItemIcon} stroke={2} />
size={18}
className={classes.menuItemIcon}
stroke={2}
/>
<span>{menuItem.label}</span> <span>{menuItem.label}</span>
</div> </div>
</UnstyledButton> </UnstyledButton>
@ -113,12 +106,10 @@ export function Navbar() {
<div className={classes.pages}> <div className={classes.pages}>
<PageTree /> <PageTree />
</div> </div>
</div> </div>
</nav> </nav>
<SearchSpotlight /> <SearchSpotlight />
<SettingsModal />
</> </>
); );
} }

View File

@ -1,37 +1,32 @@
import React from 'react'; import React from "react";
import { Avatar } from '@mantine/core'; import { Avatar } from "@mantine/core";
interface UserAvatarProps { interface UserAvatarProps {
avatarUrl: string; avatarUrl: string;
name: string; name: string;
color?: string; color?: string;
size?: string; size?: string | number;
radius?: string; radius?: string | number;
style?: any; style?: any;
component?: any; component?: any;
} }
export const UserAvatar = React.forwardRef<HTMLInputElement, UserAvatarProps>( export const UserAvatar = React.forwardRef<HTMLInputElement, UserAvatarProps>(
({ avatarUrl, name, ...props }: UserAvatarProps, ref) => { ({ avatarUrl, name, ...props }: UserAvatarProps, ref) => {
const getInitials = (name: string) => { const getInitials = (name: string) => {
const names = name?.split(' '); const names = name?.split(" ");
return names?.slice(0, 2).map(n => n[0]).join(''); return names
?.slice(0, 2)
.map((n) => n[0])
.join("");
}; };
return ( return avatarUrl ? (
avatarUrl ? ( <Avatar ref={ref} src={avatarUrl} alt={name} radius="xl" {...props} />
<Avatar ) : (
ref={ref} <Avatar ref={ref} {...props}>
src={avatarUrl} {getInitials(name)}
alt={name} </Avatar>
radius="xl"
{...props}
/>
) : (
<Avatar ref={ref}
{...props}>{getInitials(name)}</Avatar>
)
); );
}, },
); );

View File

@ -0,0 +1,6 @@
.authBackground {
position: relative;
min-height: 100vh;
background-size: cover;
background-image: url(https://images.unsplash.com/photo-1701010063921-5f3255259e6d?q=80&w=3024&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D);
}

View File

@ -15,6 +15,7 @@ import {
PasswordInput, PasswordInput,
} from '@mantine/core'; } from '@mantine/core';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import classes from './auth.module.css';
const formSchema = z.object({ const formSchema = z.object({
email: z email: z
@ -40,17 +41,11 @@ export function LoginForm() {
return ( return (
<Container size={420} my={40}> <Container size={420} my={40}>
<Title ta="center" fw={800}> <Paper shadow="md" p="lg" radius="md" mt={200}>
Login <Title ta="center" fw={800}>
</Title> Login
<Text c="dimmed" size="sm" ta="center" mt={5}> </Title>
Don't have an account yet?{' '}
<Anchor size="sm" component={Link} to="/signup">
Create account
</Anchor>
</Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={form.onSubmit(onSubmit)}> <form onSubmit={form.onSubmit(onSubmit)}>
<TextInput <TextInput
id="email" id="email"
@ -72,6 +67,14 @@ export function LoginForm() {
Sign In Sign In
</Button> </Button>
</form> </form>
<Text c="dimmed" size="sm" ta="center" mt="sm">
Don't have an account yet?{' '}
<Anchor size="sm" component={Link} to="/signup">
Create account
</Anchor>
</Text>
</Paper> </Paper>
</Container> </Container>
); );

View File

@ -50,7 +50,7 @@ export function SignUpForm() {
</Anchor> </Anchor>
</Text> </Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md"> <Paper shadow="md" p={30} mt={30} radius="md">
<form onSubmit={form.onSubmit(onSubmit)}> <form onSubmit={form.onSubmit(onSubmit)}>
<TextInput <TextInput
id="email" id="email"

View File

@ -78,7 +78,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
<Stack gap={2}> <Stack gap={2}>
<Group> <Group>
<Avatar size="sm" color="blue">{currentUser.user.name.charAt(0)}</Avatar> <Avatar size="sm" c="blue">{currentUser.user.name.charAt(0)}</Avatar>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={500} lineClamp={1}>{currentUser.user.name}</Text> <Text size="sm" fw={500} lineClamp={1}>{currentUser.user.name}</Text>

View File

@ -0,0 +1,45 @@
import { Button, Divider, Group, Modal, ScrollArea } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import React, { useState } from "react";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useParams } from "react-router-dom";
import { useAddGroupMemberMutation } from "@/features/group/queries/group-query.ts";
export default function AddGroupMemberModal() {
const { groupId } = useParams();
const [opened, { open, close }] = useDisclosure(false);
const [userIds, setUserIds] = useState<string[]>([]);
const addGroupMemberMutation = useAddGroupMemberMutation();
const handleMultiSelectChange = (value: string[]) => {
setUserIds(value);
};
const handleSubmit = async () => {
const addGroupMember = {
groupId: groupId,
userIds: userIds,
};
await addGroupMemberMutation.mutateAsync(addGroupMember);
close();
};
return (
<>
<Button onClick={open}>Add group members</Button>
<Modal opened={opened} onClose={close} title="Add group members">
<Divider size="xs" mb="xs" />
<MultiUserSelect onChange={handleMultiSelectChange} />
<Group justify="flex-end" mt="md">
<Button onClick={handleSubmit} type="submit">
Add
</Button>
</Group>
</Modal>
</>
);
}

View File

@ -0,0 +1,82 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useState } from "react";
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
const formSchema = z.object({
name: z.string().min(2).max(50),
description: z.string().max(500),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateGroupForm() {
const createGroupMutation = useCreateGroupMutation();
const [userIds, setUserIds] = useState<string[]>([]);
const navigate = useNavigate();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
description: "",
},
});
const handleMultiSelectChange = (value: string[]) => {
setUserIds(value);
};
const handleSubmit = async (data: {
name?: string;
description?: string;
}) => {
const groupData = {
name: data.name,
description: data.description,
userIds: userIds,
};
const createdGroup = await createGroupMutation.mutateAsync(groupData);
navigate(`/settings/groups/${createdGroup.id}`);
};
return (
<>
<Box maw="500" mx="auto">
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack>
<TextInput
withAsterisk
id="name"
label="Group name"
placeholder="e.g Developers"
variant="filled"
{...form.getInputProps("name")}
/>
<Textarea
id="description"
label="Group description"
placeholder="e.g Group for developers"
variant="filled"
autosize
minRows={2}
maxRows={8}
{...form.getInputProps("description")}
/>
<MultiUserSelect onChange={handleMultiSelectChange} />
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Create</Button>
</Group>
</form>
</Box>
</>
);
}

View File

@ -0,0 +1,18 @@
import { Button, Divider, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { CreateGroupForm } from "@/features/group/components/create-group-form.tsx";
export default function CreateGroupModal() {
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button onClick={open}>Create group</Button>
<Modal opened={opened} onClose={close} title="Create group">
<Divider size="xs" mb="xs" />
<CreateGroupForm />
</Modal>
</>
);
}

View File

@ -0,0 +1,88 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useEffect } from "react";
import {
useGroupQuery,
useUpdateGroupMutation,
} from "@/features/group/queries/group-query.ts";
import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useParams } from "react-router-dom";
const formSchema = z.object({
name: z.string().min(2).max(50),
description: z.string().max(500),
});
type FormValues = z.infer<typeof formSchema>;
interface EditGroupFormProps {
onClose?: () => void;
}
export function EditGroupForm({ onClose }: EditGroupFormProps) {
const updateGroupMutation = useUpdateGroupMutation();
const { isSuccess } = updateGroupMutation;
const { groupId } = useParams();
const { data: group } = useGroupQuery(groupId);
useEffect(() => {
if (isSuccess) {
if (onClose) {
onClose();
}
}
}, [isSuccess]);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: group?.name,
description: group?.description,
},
});
const handleSubmit = async (data: {
name?: string;
description?: string;
}) => {
const groupData = {
groupId: groupId,
name: data.name,
description: data.description,
};
await updateGroupMutation.mutateAsync(groupData);
};
return (
<>
<Box maw="500" mx="auto">
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack>
<TextInput
withAsterisk
id="name"
label="Group name"
placeholder="e.g Developers"
variant="filled"
{...form.getInputProps("name")}
/>
<Textarea
id="description"
label="Group description"
placeholder="e.g Group for developers"
variant="filled"
autosize
minRows={2}
maxRows={8}
{...form.getInputProps("description")}
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Edit</Button>
</Group>
</form>
</Box>
</>
);
}

View File

@ -0,0 +1,21 @@
import { Divider, Modal } from "@mantine/core";
import { EditGroupForm } from "@/features/group/components/edit-group-form.tsx";
interface EditGroupModalProps {
opened: boolean;
onClose: () => void;
}
export default function EditGroupModal({
opened,
onClose,
}: EditGroupModalProps) {
return (
<>
<Modal opened={opened} onClose={onClose} title="Edit group">
<Divider size="xs" mb="xs" />
<EditGroupForm onClose={onClose} />
</Modal>
</>
);
}

View File

@ -0,0 +1,79 @@
import {
useDeleteGroupMutation,
useGroupQuery,
} from "@/features/group/queries/group-query";
import { useNavigate, useParams } from "react-router-dom";
import { Menu, ActionIcon, Text } from "@mantine/core";
import React from "react";
import { IconDots, IconTrash } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
import { modals } from "@mantine/modals";
export default function GroupActionMenu() {
const { groupId } = useParams();
const { data: group, isLoading } = useGroupQuery(groupId);
const deleteGroupMutation = useDeleteGroupMutation();
const navigate = useNavigate();
const [opened, { open, close }] = useDisclosure(false);
const onDelete = async () => {
await deleteGroupMutation.mutateAsync(groupId);
navigate("/settings/groups");
};
const openDeleteModal = () =>
modals.openConfirmModal({
title: "Delete group",
children: (
<Text size="sm">
Are you sure you want to delete this group? Members will lose access
to resources this group has access to.
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: onDelete,
});
return (
<>
{group && (
<>
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="light">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={open} disabled={group.isDefault}>
Edit group
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
onClick={openDeleteModal}
disabled={group.isDefault}
leftSection={<IconTrash size={16} stroke={2} />}
>
Delete group
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
)}
<EditGroupModal opened={opened} onClose={close} />
</>
);
}

View File

@ -0,0 +1,33 @@
import { useGroupQuery } from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom";
import { Group, Title, Text } from "@mantine/core";
import AddGroupMemberModal from "@/features/group/components/add-group-member-modal";
import React from "react";
import { useDisclosure } from "@mantine/hooks";
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
export default function GroupDetails() {
const { groupId } = useParams();
const { data: group, isLoading } = useGroupQuery(groupId);
const [opened, { open, close }] = useDisclosure(false);
return (
<>
{group && (
<div>
{/* Todo: back navigation */}
<Title order={3}>{group.name}</Title>
<Text c="dimmed">{group.description}</Text>
<Group my="md" justify="flex-end">
<AddGroupMemberModal />
<GroupActionMenu />
</Group>
</div>
)}
<EditGroupModal opened={opened} onClose={close} />
</>
);
}

View File

@ -0,0 +1,70 @@
import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import { IconUsersGroup } from "@tabler/icons-react";
import React from "react";
import { Link } from "react-router-dom";
export default function GroupList() {
const { data, isLoading } = useGetGroupsQuery();
return (
<>
{data && (
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Group</Table.Th>
<Table.Th>Members</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((group, index) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
<Group gap="sm">
<IconUsersGroup stroke={1.5} />
<div>
<Text fz="sm" fw={500}>
{group.name}
</Text>
<Text fz="xs" c="dimmed">
{group.description}
</Text>
</div>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
{group.memberCount} members
</Anchor>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</>
);
}

View File

@ -0,0 +1,102 @@
import { Group, Table, Text, Badge, Menu, ActionIcon } from "@mantine/core";
import {
useGroupMembersQuery,
useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom";
import React from "react";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
export default function GroupMembersList() {
const { groupId } = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId);
const removeGroupMember = useRemoveGroupMemberMutation();
const onRemove = async (userId: string) => {
const memberToRemove = {
groupId: groupId,
userId: userId,
};
await removeGroupMember.mutateAsync(memberToRemove);
};
const openRemoveModal = (userId: string) =>
modals.openConfirmModal({
title: "Remove group member",
children: (
<Text size="sm">
Are you sure you want to remove this user from the group? The user
will lose access to resources this group has access to.
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: () => onRemove(userId),
});
return (
<>
{data && (
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((user, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<UserAvatar avatarUrl={user.avatarUrl} name={user.name} />
<div>
<Text fz="sm" fw={500}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
</Table.Td>
<Table.Td>
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
Remove group member
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</>
);
}

View File

@ -0,0 +1,73 @@
import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import {
Avatar,
Group,
MultiSelect,
MultiSelectProps,
Text,
} from "@mantine/core";
interface MultiUserSelectProps {
onChange: (value: string[]) => void;
}
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
option,
}) => (
<Group gap="sm">
<Avatar src={option?.["avatarUrl"]} size={36} radius="xl" />
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" opacity={0.5}>
{option?.["email"]}
</Text>
</div>
</Group>
);
export function MultiUserSelect({ onChange }: MultiUserSelectProps) {
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: users, isLoading } = useWorkspaceMembersQuery({
query: debouncedQuery,
limit: 50,
});
const [data, setData] = useState([]);
useEffect(() => {
if (users) {
const usersData = users?.items.map((user: IUser) => {
return {
value: user.id,
label: user.name,
avatarUrl: user.avatarUrl,
email: user.email,
};
});
if (usersData.length > 0) {
setData(usersData);
}
}
}, [users]);
return (
<MultiSelect
data={data}
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
label="Add group members"
placeholder="Search for users"
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
variant="filled"
onChange={onChange}
nothingFoundMessage="Nothing found..."
maxValues={50}
/>
);
}

View File

@ -0,0 +1,134 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { IGroup } from "@/features/group/types/group.types";
import {
addGroupMember,
createGroup,
deleteGroup,
getGroupById,
getGroupMembers,
getGroups,
removeGroupMember,
updateGroup,
} from "@/features/group/services/group-service";
import { notifications } from "@mantine/notifications";
export function useGetGroupsQuery(): UseQueryResult<any, Error> {
return useQuery({
queryKey: ["groups"],
queryFn: () => getGroups(),
});
}
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
return useQuery({
queryKey: ["group", groupId],
queryFn: () => getGroupById(groupId),
enabled: !!groupId,
});
}
export function useGroupMembersQuery(groupId: string) {
return useQuery({
queryKey: ["groupMembers", groupId],
queryFn: () => getGroupMembers(groupId),
enabled: !!groupId,
});
}
export function useCreateGroupMutation() {
return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => createGroup(data),
onSuccess: () => {
notifications.show({ message: "Group created successfully" });
},
onError: () => {
notifications.show({ message: "Failed to create group", color: "red" });
},
});
}
export function useUpdateGroupMutation() {
const queryClient = useQueryClient();
return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => updateGroup(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Group updated successfully" });
queryClient.invalidateQueries({
queryKey: ["group", variables.groupId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useDeleteGroupMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (groupId: string) => deleteGroup({ groupId }),
onSuccess: (data, variables) => {
notifications.show({ message: "Group deleted successfully" });
queryClient.refetchQueries({
queryKey: ["groups"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useAddGroupMemberMutation() {
const queryClient = useQueryClient();
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Added successfully" });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});
},
onError: () => {
notifications.show({
message: "Failed to add group members",
color: "red",
});
},
});
}
export function useRemoveGroupMemberMutation() {
const queryClient = useQueryClient();
return useMutation<
void,
Error,
{
groupId: string;
userId: string;
}
>({
mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Removed successfully" });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}

View File

@ -0,0 +1,46 @@
import api from "@/lib/api-client";
import { IGroup } from "@/features/group/types/group.types";
export async function getGroups(): Promise<any> {
// TODO: returns paginated. Fix type
const req = await api.post<any>("/groups");
return req.data;
}
export async function getGroupById(groupId: string): Promise<IGroup> {
const req = await api.post<IGroup>("/groups/info", { groupId });
return req.data as IGroup;
}
export async function getGroupMembers(groupId: string) {
const req = await api.post("/groups/members", { groupId });
return req.data;
}
export async function createGroup(data: Partial<IGroup>): Promise<IGroup> {
const req = await api.post<IGroup>("/groups/create", data);
return req.data;
}
export async function updateGroup(data: Partial<IGroup>): Promise<IGroup> {
const req = await api.post<IGroup>("/groups/update", data);
return req.data;
}
export async function deleteGroup(data: { groupId: string }): Promise<void> {
await api.post("/groups/delete", data);
}
export async function addGroupMember(data: {
groupId: string;
userIds: string[];
}): Promise<void> {
await api.post<IGroup>("/groups/members/add", data);
}
export async function removeGroupMember(data: {
groupId: string;
userId: string;
}): Promise<void> {
await api.post<IGroup>("/groups/members/remove", data);
}

View File

@ -0,0 +1,12 @@
export interface IGroup {
groupId: string;
id: string;
name: string;
description: string | null;
isDefault: boolean;
creatorId: string | null;
workspaceId: string;
createdAt: Date;
updatedAt: Date;
memberCount: number;
}

View File

@ -22,7 +22,7 @@ export default function HomeTabs() {
<Tabs.Panel value="recent"> <Tabs.Panel value="recent">
<RecentChanges /> {/* <RecentChanges /> */}
</Tabs.Panel> </Tabs.Panel>

View File

@ -24,7 +24,7 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<UserAvatar color="blue" size="sm" avatarUrl={historyItem.lastUpdatedBy.avatarUrl} <UserAvatar c="blue" size="sm" avatarUrl={historyItem.lastUpdatedBy.avatarUrl}
name={historyItem.lastUpdatedBy.name} /> name={historyItem.lastUpdatedBy.name} />
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy.name} {historyItem.lastUpdatedBy.name}

View File

@ -7,7 +7,7 @@ export async function createPage(data: Partial<IPage>): Promise<IPage> {
} }
export async function getPageById(id: string): Promise<IPage> { export async function getPageById(id: string): Promise<IPage> {
const req = await api.post<IPage>('/pages/details', { id }); const req = await api.post<IPage>('/pages/info', { id });
return req.data as IPage; return req.data as IPage;
} }

View File

@ -1,4 +1,4 @@
import { NodeApi, NodeRendererProps, Tree, TreeApi } from 'react-arborist'; import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
import { import {
IconArrowsLeftRight, IconArrowsLeftRight,
IconChevronDown, IconChevronDown,
@ -11,24 +11,27 @@ import {
IconPlus, IconPlus,
IconStar, IconStar,
IconTrash, IconTrash,
} from '@tabler/icons-react'; } from "@tabler/icons-react";
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from "react";
import clsx from 'clsx'; import clsx from "clsx";
import classes from './styles/tree.module.css'; import classes from "./styles/tree.module.css";
import { ActionIcon, Menu, rem } from '@mantine/core'; import { ActionIcon, Menu, rem } from "@mantine/core";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { FillFlexParent } from './components/fill-flex-parent'; import { FillFlexParent } from "./components/fill-flex-parent";
import { TreeNode } from './types'; import { TreeNode } from "./types";
import { treeApiAtom } from './atoms/tree-api-atom'; import { treeApiAtom } from "./atoms/tree-api-atom";
import { usePersistence } from '@/features/page/tree/hooks/use-persistence'; import { usePersistence } from "@/features/page/tree/hooks/use-persistence";
import useWorkspacePageOrder from '@/features/page/tree/hooks/use-workspace-page-order'; import useWorkspacePageOrder from "@/features/page/tree/hooks/use-workspace-page-order";
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from "react-router-dom";
import { convertToTree, updateTreeNodeIcon } from '@/features/page/tree/utils'; import { convertToTree, updateTreeNodeIcon } from "@/features/page/tree/utils";
import { useGetPagesQuery, useUpdatePageMutation } from '@/features/page/queries/page-query'; import {
import EmojiPicker from '@/components/emoji-picker'; useGetPagesQuery,
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom'; useUpdatePageMutation,
} from "@/features/page/queries/page-query";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
export default function PageTree() { export default function PageTree() {
const { data, setData, controllers } = usePersistence<TreeApi<TreeNode>>(); const { data, setData, controllers } = usePersistence<TreeApi<TreeNode>>();
@ -46,7 +49,7 @@ export default function PageTree() {
setData(treeData); setData(treeData);
} }
} catch (err) { } catch (err) {
console.error('Error fetching tree data: ', err); console.error("Error fetching tree data: ", err);
} }
} }
}; };
@ -58,7 +61,7 @@ export default function PageTree() {
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
tree?.select(pageId); tree?.select(pageId);
tree?.scrollTo(pageId, 'center'); tree?.scrollTo(pageId, "center");
}, 200); }, 200);
}, [tree, pageId]); }, [tree, pageId]);
@ -106,7 +109,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
const handleEmojiIconClick = (e) => { const handleEmojiIconClick = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} };
const handleEmojiSelect = (emoji) => { const handleEmojiSelect = (emoji) => {
handleUpdateNodeIcon(node.id, emoji.native); handleUpdateNodeIcon(node.id, emoji.native);
@ -134,19 +137,25 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
> >
<PageArrow node={node} /> <PageArrow node={node} />
<div onClick={handleEmojiIconClick} style={{ marginRight: '4px' }}> <div onClick={handleEmojiIconClick} style={{ marginRight: "4px" }}>
<EmojiPicker onEmojiSelect={handleEmojiSelect} icon={ <EmojiPicker
node.data.icon ? node.data.icon : onEmojiSelect={handleEmojiSelect}
<IconFileDescription size="18px" /> icon={
node.data.icon ? (
} removeEmojiAction={handleRemoveEmoji}/> node.data.icon
) : (
<IconFileDescription size="18px" />
)
}
removeEmojiAction={handleRemoveEmoji}
/>
</div> </div>
<span className={classes.text}> <span className={classes.text}>
{node.isEditing ? ( {node.isEditing ? (
<Input node={node} /> <Input node={node} />
) : ( ) : (
node.data.name || 'untitled' node.data.name || "untitled"
)} )}
</span> </span>
@ -163,15 +172,19 @@ function CreateNode({ node }: { node: NodeApi<TreeNode> }) {
const [tree] = useAtom(treeApiAtom); const [tree] = useAtom(treeApiAtom);
function handleCreate() { function handleCreate() {
tree?.create({ type: 'internal', parentId: node.id, index: 0 }); tree?.create({ type: "internal", parentId: node.id, index: 0 });
} }
return ( return (
<ActionIcon variant="transparent" color="gray" onClick={(e) => { <ActionIcon
e.preventDefault(); variant="transparent"
e.stopPropagation(); c="gray"
handleCreate(); onClick={(e) => {
}}> e.preventDefault();
e.stopPropagation();
handleCreate();
}}
>
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} /> <IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
</ActionIcon> </ActionIcon>
); );
@ -187,10 +200,14 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
return ( return (
<Menu shadow="md" width={200}> <Menu shadow="md" width={200}>
<Menu.Target> <Menu.Target>
<ActionIcon variant="transparent" color="gray" onClick={(e) => { <ActionIcon
e.preventDefault(); variant="transparent"
e.stopPropagation(); c="gray"
}}> onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<IconDotsVertical <IconDotsVertical
style={{ width: rem(20), height: rem(20) }} style={{ width: rem(20), height: rem(20) }}
stroke={2} stroke={2}
@ -240,7 +257,7 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
Archive Archive
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
color="red" c="red"
leftSection={ leftSection={
<IconTrash style={{ width: rem(14), height: rem(14) }} /> <IconTrash style={{ width: rem(14), height: rem(14) }} />
} }
@ -255,13 +272,16 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
function PageArrow({ node }: { node: NodeApi<TreeNode> }) { function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
return ( return (
<ActionIcon size={20} variant="subtle" color="gray" <ActionIcon
onClick={(e) => { size={20}
e.preventDefault(); variant="subtle"
e.stopPropagation(); c="gray"
node.toggle(); onClick={(e) => {
}}> e.preventDefault();
e.stopPropagation();
node.toggle();
}}
>
{node.isInternal ? ( {node.isInternal ? (
node.children && node.children.length > 0 ? ( node.children && node.children.length > 0 ? (
node.isOpen ? ( node.isOpen ? (
@ -270,7 +290,7 @@ function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
<IconChevronRight stroke={2} size={18} /> <IconChevronRight stroke={2} size={18} />
) )
) : ( ) : (
<IconChevronRight size={18} style={{ visibility: 'hidden' }} /> <IconChevronRight size={18} style={{ visibility: "hidden" }} />
) )
) : null} ) : null}
</ActionIcon> </ActionIcon>
@ -278,7 +298,6 @@ function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
} }
function Input({ node }: { node: NodeApi<TreeNode> }) { function Input({ node }: { node: NodeApi<TreeNode> }) {
return ( return (
<input <input
autoFocus autoFocus
@ -289,10 +308,9 @@ function Input({ node }: { node: NodeApi<TreeNode> }) {
onFocus={(e) => e.currentTarget.select()} onFocus={(e) => e.currentTarget.select()}
onBlur={() => node.reset()} onBlur={() => node.reset()}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Escape') node.reset(); if (e.key === "Escape") node.reset();
if (e.key === 'Enter') node.submit(e.currentTarget.value); if (e.key === "Enter") node.submit(e.currentTarget.value);
}} }}
/> />
); );
} }

View File

@ -1,3 +0,0 @@
import { atom } from "jotai";
export const settingsModalAtom = atom<boolean>(false);

View File

@ -1,76 +0,0 @@
.sidebar {
max-height: rem(700px);
width: rem(180px);
padding: var(--mantine-spacing-sm);
display: flex;
flex-direction: column;
border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.sidebarFlex {
display: flex;
}
.sidebarMain {
flex: 1;
}
.sidebarRightSection {
flex: 1;
padding: rem(16px) rem(40px);
}
.sidebarItemHeader {
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;
}
.sidebarItem {
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: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
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));
.sidebarItemIcon {
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
}
&[data-active] {
&,
& :hover {
background-color: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-light-color);
.sidebarItemIcon {
color: var(--mantine-color-blue-light-color);
}
}
}
}
.sidebarItemIcon {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-right: var(--mantine-spacing-sm);
width: rem(20px);
height: rem(20px);
}

View File

@ -1,30 +0,0 @@
import { Modal, Text } from '@mantine/core';
import React from 'react';
import SettingsSidebar from '@/features/settings/modal/settings-sidebar';
import { useAtom } from 'jotai';
import { settingsModalAtom } from '@/features/settings/modal/atoms/settings-modal-atom';
export default function SettingsModal() {
const [isModalOpen, setModalOpen] = useAtom(settingsModalAtom);
return (
<>
<Modal.Root size={1000} opened={isModalOpen} onClose={() => setModalOpen(false)}>
<Modal.Overlay />
<Modal.Content style={{ overflow: 'hidden' }}>
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>Settings</Text>
</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<SettingsSidebar />
</Modal.Body>
</Modal.Content>
</Modal.Root>
</>
);
}

View File

@ -1,101 +0,0 @@
import React, { useState } from 'react';
import classes from '@/features/settings/modal/modal.module.css';
import { IconBell, IconFingerprint, IconReceipt, IconSettingsCog, IconUser, IconUsers } from '@tabler/icons-react';
import { Loader, ScrollArea, Text } from '@mantine/core';
const AccountSettings = React.lazy(() => import('@/features/settings/account/settings/account-settings'));
const WorkspaceSettings = React.lazy(() => import('@/features/settings/workspace/settings/workspace-settings'));
const WorkspaceMembers = React.lazy(() => import('@/features/settings/workspace/members/workspace-members'));
interface DataItem {
label: string;
icon: React.ElementType;
}
interface DataGroup {
heading: string;
items: DataItem[];
}
const groupedData: DataGroup[] = [
{
heading: 'Account',
items: [
{ label: 'Account', icon: IconUser },
{ label: 'Notifications', icon: IconBell },
],
},
{
heading: 'Workspace',
items: [
{ label: 'General', icon: IconSettingsCog },
{ label: 'Members', icon: IconUsers },
{ label: 'Security', icon: IconFingerprint },
{ label: 'Billing', icon: IconReceipt },
],
},
];
export default function SettingsSidebar() {
const [active, setActive] = useState('Account');
const menu = groupedData.map((group) => (
<div key={group.heading}>
<Text c="dimmed" className={classes.sidebarItemHeader}>{group.heading}</Text>
{group.items.map((item) => (
<div
className={classes.sidebarItem}
data-active={item.label === active || undefined}
key={item.label}
onClick={(event) => {
event.preventDefault();
setActive(item.label);
}}
>
<item.icon className={classes.sidebarItemIcon} stroke={1.5} />
<span>{item.label}</span>
</div>
))}
</div>
));
let ActiveComponent;
switch (active) {
case 'Account':
ActiveComponent = AccountSettings;
break;
case 'General':
ActiveComponent = WorkspaceSettings;
break;
case 'Members':
ActiveComponent = WorkspaceMembers;
break;
default:
ActiveComponent = null;
}
return (
<div className={classes.sidebarFlex}>
<nav className={classes.sidebar}>
<div className={classes.sidebarMain}>
{menu}
</div>
</nav>
<ScrollArea h="650" w="100%" scrollbarSize={4}>
<div className={classes.sidebarRightSection}>
<React.Suspense fallback={<Loader size="sm" color="gray" />}>
{ActiveComponent && <ActiveComponent />}
</React.Suspense>
</div>
</ScrollArea>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { atomWithStorage } from "jotai/utils"; import { atomWithStorage } from "jotai/utils";
import { ICurrentUserResponse } from "@/features/user/types/user.types"; import { ICurrentUser } from "@/features/user/types/user.types";
export const currentUserAtom = atomWithStorage<ICurrentUserResponse | null>("currentUser", null); export const currentUserAtom = atomWithStorage<ICurrentUser | null>("currentUser", null);

View File

@ -0,0 +1,60 @@
import { focusAtom } from "jotai-optics";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useAtom } from "jotai";
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
import { FileButton, Button, Text, Popover, Tooltip } from "@mantine/core";
import { uploadAvatar } from "@/features/user/services/user-service.ts";
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
export default function AccountAvatar() {
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const handleFileChange = async (selectedFile: File) => {
if (!selectedFile) {
return;
}
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setFile(selectedFile);
setPreviewUrl(URL.createObjectURL(selectedFile));
try {
setIsLoading(true);
const upload = await uploadAvatar(selectedFile);
console.log(upload);
} catch (err) {
console.log(err);
} finally {
setIsLoading(false);
}
};
return (
<>
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
{(props) => (
<Tooltip label="Change photo" position="bottom">
<UserAvatar
{...props}
component="button"
radius="xl"
size="60px"
avatarUrl={previewUrl || currentUser.user.avatarUrl}
name={currentUser.user.name}
style={{ cursor: "pointer" }}
/>
</Tooltip>
)}
</FileButton>
</>
);
}

View File

@ -0,0 +1,76 @@
import { useAtom } from "jotai";
import { focusAtom } from "jotai-optics";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import { useState } from "react";
import { TextInput, Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
const formSchema = z.object({
name: z.string().min(2).max(40).nonempty("Your name cannot be blank"),
});
type FormValues = z.infer<typeof formSchema>;
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
export default function AccountNameForm() {
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: currentUser?.user.name,
},
});
async function handleSubmit(data: Partial<IUser>) {
setIsLoading(true);
try {
const updatedUser = await updateUser(data);
setUser(updatedUser);
notifications.show({
message: "Updated successfully",
});
} catch (err) {
console.log(err);
notifications.show({
message: "Failed to update data",
color: "red",
});
}
setIsLoading(false);
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
id="name"
label="Name"
placeholder="Your name"
variant="filled"
{...form.getInputProps("name")}
/>
<Button type="submit" mt="sm" disabled={isLoading} loading={isLoading}>
Save
</Button>
</form>
);
}
/*
<div className={classes.controls}>
<TextInput
placeholder="Your email"
classNames={{ input: classes.input, root: classes.inputWrapper }}
/>
<Button className={classes.control}>Subscribe</Button>
</div>
*/

View File

@ -0,0 +1,94 @@
import {
Modal,
TextInput,
Button,
Text,
Group,
PasswordInput,
} from "@mantine/core";
import * as z from "zod";
import { useState } from "react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useDisclosure } from "@mantine/hooks";
import * as React from "react";
import { useForm, zodResolver } from "@mantine/form";
export default function ChangeEmail() {
const [currentUser] = useAtom(currentUserAtom);
const [opened, { open, close }] = useDisclosure(false);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Email</Text>
<Text size="sm" c="dimmed">
{currentUser?.user.email}
</Text>
</div>
<Button onClick={open} variant="default">
Change email
</Button>
<Modal opened={opened} onClose={close} title="Change email" centered>
<Text mb="md">
To change your email, you have to enter your password and new email.
</Text>
<ChangePasswordForm />
</Modal>
</Group>
);
}
const formSchema = z.object({
email: z.string({ required_error: "New email is required" }).email(),
password: z
.string({ required_error: "your current password is required" })
.min(8),
});
type FormValues = z.infer<typeof formSchema>;
function ChangePasswordForm() {
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
password: "",
email: "",
},
});
function handleSubmit(data: FormValues) {
setIsLoading(true);
console.log(data);
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<PasswordInput
label="Password"
placeholder="Enter your password"
variant="filled"
mb="md"
{...form.getInputProps("password")}
/>
<TextInput
id="email"
label="Email"
description="Enter your new preferred email"
placeholder="New email"
variant="filled"
mb="md"
{...form.getInputProps("email")}
/>
<Button type="submit" disabled={isLoading} loading={isLoading}>
Change email
</Button>
</form>
);
}

View File

@ -0,0 +1,84 @@
import { Button, Group, Text, Modal, PasswordInput } from '@mantine/core';
import * as z from 'zod';
import { useState } from 'react';
import { useDisclosure } from '@mantine/hooks';
import * as React from 'react';
import { useForm, zodResolver } from '@mantine/form';
export default function ChangePassword() {
const [opened, { open, close }] = useDisclosure(false);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Password</Text>
<Text size="sm" c="dimmed">
You can change your password here.
</Text>
</div>
<Button onClick={open} variant="default">Change password</Button>
<Modal opened={opened} onClose={close} title="Change password" centered>
<Text mb="md">Your password must be a minimum of 8 characters.</Text>
<ChangePasswordForm />
</Modal>
</Group>
);
}
const formSchema = z.object({
current: z.string({ required_error: 'your current password is required' }).min(1),
password: z.string({ required_error: 'New password is required' }).min(8),
confirm_password: z.string({ required_error: 'Password confirmation is required' }).min(8),
}).refine(data => data.password === data.confirm_password, {
message: 'Your new password and confirmation does not match.',
path: ['confirm_password'],
});
type FormValues = z.infer<typeof formSchema>
function ChangePasswordForm() {
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
current: '',
password: '',
confirm_password: '',
},
});
function handleSubmit(data: FormValues) {
setIsLoading(true);
console.log(data);
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<PasswordInput
label="Current password"
name="current"
placeholder="Enter your current password"
variant="filled"
mb="md"
{...form.getInputProps('current')}
/>
<PasswordInput
label="New password"
placeholder="Enter your new password"
variant="filled"
mb="md"
{...form.getInputProps('password')}
/>
<Button type="submit" disabled={isLoading} loading={isLoading}>
Change password
</Button>
</form>
);
}

View File

@ -1,8 +1,8 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getUserInfo } from "@/features/user/services/user-service"; import { getUserInfo } from "@/features/user/services/user-service";
import { ICurrentUserResponse } from "@/features/user/types/user.types"; import { ICurrentUser } from "@/features/user/types/user.types";
export default function useCurrentUser(): UseQueryResult<ICurrentUserResponse> { export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
return useQuery({ return useQuery({
queryKey: ["currentUser"], queryKey: ["currentUser"],
queryFn: async () => { queryFn: async () => {

View File

@ -1,19 +1,18 @@
import api from '@/lib/api-client'; import api from '@/lib/api-client';
import { ICurrentUserResponse, IUser } from '@/features/user/types/user.types'; import { ICurrentUser, IUser } from '@/features/user/types/user.types';
export async function getMe(): Promise<IUser> { export async function getMe(): Promise<IUser> {
const req = await api.get<IUser>('/user/me'); const req = await api.post<IUser>('/users/me');
return req.data as IUser; return req.data as IUser;
} }
export async function getUserInfo(): Promise<ICurrentUserResponse> { export async function getUserInfo(): Promise<ICurrentUser> {
const req = await api.get<ICurrentUserResponse>('/user/info'); const req = await api.post<ICurrentUser>('/users/info');
return req.data as ICurrentUserResponse; return req.data as ICurrentUser;
} }
export async function updateUser(data: Partial<IUser>): Promise<IUser> { export async function updateUser(data: Partial<IUser>): Promise<IUser> {
const req = await api.post<IUser>('/user/update', data); const req = await api.post<IUser>('/users/update', data);
return req.data as IUser; return req.data as IUser;
} }

View File

@ -9,13 +9,13 @@ export interface IUser {
timezone: string; timezone: string;
settings: any; settings: any;
lastLoginAt: string; lastLoginAt: string;
lastLoginIp: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
workspaceRole?: string; role: string;
workspaceId: string;
} }
export interface ICurrentUserResponse { export interface ICurrentUser {
user: IUser, user: IUser,
workspace: IWorkspace workspace: IWorkspace
} }

View File

@ -1,5 +1,5 @@
import { Group, Box, Button, TagsInput, Space, Select } from "@mantine/core"; import { Group, Box, Button, TagsInput, Space, Select } from "@mantine/core";
import WorkspaceInviteSection from "@/features/settings/workspace/members/components/workspace-invite-section"; import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section.tsx";
import React from "react"; import React from "react";
enum UserRole { enum UserRole {

View File

@ -1,27 +1,27 @@
import { WorkspaceInviteForm } from '@/features/settings/workspace/members/components/workspace-invite-form'; import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form.tsx";
import { Button, Divider, Modal, ScrollArea } from '@mantine/core'; import { Button, Divider, Modal, ScrollArea } from "@mantine/core";
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from "@mantine/hooks";
export default function WorkspaceInviteModal() { export default function WorkspaceInviteModal() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
return ( return (
<> <>
<Button onClick={open}> <Button onClick={open}>Invite members</Button>
Invite members
</Button>
<Modal size="600" opened={opened} onClose={close} title="Invite new members" centered> <Modal
size="600"
<Divider size="xs" mb="xs"/> opened={opened}
onClose={close}
title="Invite new members"
centered
>
<Divider size="xs" mb="xs" />
<ScrollArea h="80%"> <ScrollArea h="80%">
<WorkspaceInviteForm /> <WorkspaceInviteForm />
</ScrollArea> </ScrollArea>
</Modal> </Modal>
</> </>
); );
} }

View File

@ -1,5 +1,5 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core"; import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";

View File

@ -1,5 +1,5 @@
import { Group, Table, Avatar, Text, Badge } from "@mantine/core"; import { Group, Table, Avatar, Text, Badge } from "@mantine/core";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query"; import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query.ts";
import { UserAvatar } from "@/components/ui/user-avatar.tsx"; import { UserAvatar } from "@/components/ui/user-avatar.tsx";
import React from "react"; import React from "react";

View File

@ -1,10 +1,10 @@
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import * as z from "zod"; import * as z from "zod";
import { useState } from "react"; import { useState } from "react";
import { focusAtom } from "jotai-optics"; import { focusAtom } from "jotai-optics";
import { updateWorkspace } from "@/features/workspace/services/workspace-service"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types"; import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { TextInput, Button } from "@mantine/core"; import { TextInput, Button } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";

View File

@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service";
import { QueryParams } from "@/lib/types.ts";
export function useWorkspaceMembersQuery(params?: QueryParams) {
return useQuery({
queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params),
});
}

View File

@ -1,19 +1,21 @@
import api from '@/lib/api-client'; import api from "@/lib/api-client";
import { ICurrentUserResponse, IUser } from '@/features/user/types/user.types'; import { IUser } from "@/features/user/types/user.types";
import { IWorkspace } from '../types/workspace.types'; import { IWorkspace } from "../types/workspace.types";
import { QueryParams } from "@/lib/types.ts";
export async function getWorkspace(): Promise<IWorkspace> { export async function getWorkspace(): Promise<IWorkspace> {
const req = await api.get<IWorkspace>('/workspace'); const req = await api.post<IWorkspace>("/workspace/info");
return req.data as IWorkspace; return req.data as IWorkspace;
} }
export async function getWorkspaceUsers(): Promise<IUser[]> { // Todo: fix all paginated types
const req = await api.get<IUser[]>('/workspace/members'); export async function getWorkspaceMembers(params?: QueryParams): Promise<any> {
return req.data as IUser[]; const req = await api.post<any>("/workspace/members", params);
return req.data;
} }
export async function updateWorkspace(data: Partial<IWorkspace>) { export async function updateWorkspace(data: Partial<IWorkspace>) {
const req = await api.post<IWorkspace>('/workspace/update', data); const req = await api.post<IWorkspace>("/workspace/update", data);
return req.data as IWorkspace; return req.data as IWorkspace;
} }

View File

@ -8,8 +8,6 @@ export interface IWorkspace {
enableInvite: boolean; enableInvite: boolean;
inviteCode: string; inviteCode: string;
settings: any; settings: any;
creatorId: string;
pageOrder?:[]
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }

View File

@ -3,10 +3,11 @@ import Cookies from "js-cookie";
import Routes from "@/lib/routes"; import Routes from "@/lib/routes";
const api: AxiosInstance = axios.create({ const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_BACKEND_API_URL + '/api' baseURL: import.meta.env.VITE_BACKEND_API_URL + "/api",
}); });
api.interceptors.request.use(config => { api.interceptors.request.use(
(config) => {
const tokenData = Cookies.get("authTokens"); const tokenData = Cookies.get("authTokens");
const accessToken = tokenData && JSON.parse(tokenData)?.accessToken; const accessToken = tokenData && JSON.parse(tokenData)?.accessToken;
@ -15,23 +16,23 @@ api.interceptors.request.use(config => {
} }
return config; return config;
}, },
error => { (error) => {
return Promise.reject(error); return Promise.reject(error);
}, },
); );
api.interceptors.response.use( api.interceptors.response.use(
response => { (response) => {
return response.data; return response.data;
}, },
error => { (error) => {
if (error.response) { if (error.response) {
switch (error.response.status) { switch (error.response.status) {
case 401: case 401:
// Handle unauthorized error // Handle unauthorized error
if (window.location.pathname != Routes.AUTH.LOGIN){ if (window.location.pathname != Routes.AUTH.LOGIN) {
window.location.href = Routes.AUTH.LOGIN; window.location.href = Routes.AUTH.LOGIN;
} }
break; break;
case 403: case 403:
// Handle forbidden error // Handle forbidden error

View File

@ -0,0 +1,5 @@
export interface QueryParams {
query?: string;
page?: number;
limit?: number;
}

View File

@ -18,7 +18,7 @@ root.render(
<MantineProvider theme={theme}> <MantineProvider theme={theme}>
<ModalsProvider> <ModalsProvider>
<TanstackProvider> <TanstackProvider>
<Notifications position="top-center" limit={3} /> <Notifications position="top-right" limit={3} />
<App /> <App />
</TanstackProvider> </TanstackProvider>
</ModalsProvider> </ModalsProvider>

View File

@ -0,0 +1,26 @@
import AccountNameForm from "@/features/user/components/account-name-form";
import ChangeEmail from "@/features/user/components/change-email";
import ChangePassword from "@/features/user/components/change-password";
import { Divider } from "@mantine/core";
import AccountAvatar from "@/features/user/components/account-avatar";
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
export default function AccountSettings() {
return (
<>
<SettingsTitle title="My Profile" />
<AccountAvatar />
<AccountNameForm />
<Divider my="lg" />
<ChangeEmail />
<Divider my="lg" />
<ChangePassword />
</>
);
}

View File

@ -0,0 +1,13 @@
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
import GroupMembersList from "@/features/group/components/group-members";
import GroupDetails from "@/features/group/components/group-details";
export default function GroupInfo() {
return (
<>
<SettingsTitle title="Manage Group" />
<GroupDetails />
<GroupMembersList />
</>
);
}

View File

@ -0,0 +1,18 @@
import GroupList from "@/features/group/components/group-list";
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
import { Group, Text } from "@mantine/core";
import CreateGroupModal from "@/features/group/components/create-group-modal";
export default function Groups() {
return (
<>
<SettingsTitle title="Groups" />
<Group my="md" justify="flex-end">
<CreateGroupModal />
</Group>
<GroupList />
</>
);
}

View File

@ -0,0 +1,26 @@
import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section";
import WorkspaceInviteModal from "@/features/workspace/components/members/components/workspace-invite-modal";
import { Divider, Group, Space, Text } from "@mantine/core";
import WorkspaceMembersTable from "@/features/workspace/components/members/components/workspace-members-table";
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
export default function WorkspaceMembers() {
return (
<>
<SettingsTitle title="Members" />
<WorkspaceInviteSection />
<Divider my="lg" />
<Group justify="space-between">
<Text fw={500}>Members</Text>
<WorkspaceInviteModal />
</Group>
<Space h="lg" />
<WorkspaceMembersTable />
</>
);
}

View File

@ -0,0 +1,11 @@
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
export default function WorkspaceSettings() {
return (
<>
<SettingsTitle title="General" />
<WorkspaceNameForm />
</>
);
}

View File

@ -1,5 +1,21 @@
import { createTheme } from '@mantine/core'; import { createTheme, MantineColorsTuple } from '@mantine/core';
const blue: MantineColorsTuple = [
'#e8f3ff',
'#d0e3ff',
'#9ec4fc',
'#69a3fb',
'#4087fa',
'#2975fa',
'#0052cc', //1c6cfb
'#0f5be1',
'#0051c9',
'#0046b1',
];
export const theme = createTheme({ export const theme = createTheme({
colors: {
blue,
},
}); });