mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 04:32:41 +10:00
client: updates
* work on groups ui * move settings to its own page * other fixes and refactoring
This commit is contained in:
@ -1,25 +1,39 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Welcome } from '@/pages/welcome';
|
||||
import SignUpPage from '@/pages/auth/signup';
|
||||
import LoginPage from '@/pages/auth/login';
|
||||
import DashboardLayout from '@/components/layouts/layout';
|
||||
import Home from '@/pages/dashboard/home';
|
||||
import Page from '@/pages/page/page';
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { Welcome } from "@/pages/welcome";
|
||||
import SignUpPage from "@/pages/auth/signup";
|
||||
import LoginPage from "@/pages/auth/login";
|
||||
import DashboardLayout from "@/components/layouts/dashboard/dashboard-layout.tsx";
|
||||
import Home from "@/pages/dashboard/home";
|
||||
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() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route index element={<Welcome />} />
|
||||
<Route path={'/login'} element={<LoginPage />} />
|
||||
<Route path={'/signup'} element={<SignUpPage />} />
|
||||
<Route path={"/login"} element={<LoginPage />} />
|
||||
<Route path={"/signup"} element={<SignUpPage />} />
|
||||
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path={'/home'} element={<Home />} />
|
||||
<Route path={'/p/:pageId'} element={<Page />} />
|
||||
<Route path={"/home"} element={<Home />} />
|
||||
<Route path={"/p/:pageId"} element={<Page />} />
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -66,7 +66,7 @@ export default function Breadcrumb() {
|
||||
</Anchor>,
|
||||
<Popover width={250} position="bottom" withArrow shadow="xl" key="hidden-nodes">
|
||||
<Popover.Target>
|
||||
<ActionIcon color="gray" variant="transparent">
|
||||
<ActionIcon c="gray" variant="transparent">
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
|
||||
42
apps/client/src/components/layouts/dashboard/aside.tsx
Normal file
42
apps/client/src/components/layouts/dashboard/aside.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,8 @@
|
||||
import { UserProvider } from '@/features/user/user-provider';
|
||||
import Shell from './shell';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import Shell from "./shell.tsx";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export default function DashboardLayout() {
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<Shell>
|
||||
@ -1,8 +1,4 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Menu,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
import { ActionIcon, Menu, Button } from "@mantine/core";
|
||||
import {
|
||||
IconDots,
|
||||
IconFileInfo,
|
||||
@ -12,22 +8,26 @@ import {
|
||||
IconShare,
|
||||
IconTrash,
|
||||
IconMessage,
|
||||
} from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
import useToggleAside from '@/hooks/use-toggle-aside';
|
||||
import { useAtom } from 'jotai';
|
||||
import { historyAtoms } from '@/features/page-history/atoms/history-atoms';
|
||||
} 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";
|
||||
|
||||
export default function Header() {
|
||||
const toggleAside = useToggleAside();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="default" style={{ border: 'none' }} size="compact-sm">
|
||||
<Button variant="default" style={{ border: "none" }} size="compact-sm">
|
||||
Share
|
||||
</Button>
|
||||
|
||||
<ActionIcon variant="default" style={{ border: 'none' }} onClick={() => toggleAside('comments')}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
onClick={() => toggleAside("comments")}
|
||||
>
|
||||
<IconMessage size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
@ -53,38 +53,33 @@ function PageActionMenu() {
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: 'none' }}>
|
||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileInfo size={16} stroke={2} />}>
|
||||
<Menu.Item leftSection={<IconFileInfo size={16} stroke={2} />}>
|
||||
Page info
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} stroke={2} />}
|
||||
>
|
||||
<Menu.Item leftSection={<IconLink size={16} stroke={2} />}>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconShare size={16} stroke={2} />}>
|
||||
<Menu.Item leftSection={<IconShare size={16} stroke={2} />}>
|
||||
Share
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} stroke={2} />}
|
||||
onClick={openHistoryModal}>
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
Page history
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconLock size={16} stroke={2} />}>
|
||||
<Menu.Item leftSection={<IconLock size={16} stroke={2} />}>
|
||||
Lock
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={16} stroke={2} />}>
|
||||
<Menu.Item leftSection={<IconTrash size={16} stroke={2} />}>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
|
||||
@ -1,22 +1,25 @@
|
||||
import { asideStateAtom, desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||
import { useToggleSidebar } from '@/components/navbar/hooks/use-toggle-sidebar';
|
||||
import { Navbar } from '@/components/navbar/navbar';
|
||||
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/header';
|
||||
import Breadcrumb from '@/components/layouts/components/breadcrumb';
|
||||
import Aside from '@/components/aside/aside';
|
||||
import { useMatchPath } from '@/hooks/use-match-path';
|
||||
import React from 'react';
|
||||
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 }] = useDisclosure();
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
const matchPath = useMatchPath();
|
||||
const isPageRoute = matchPath('/p/:pageId');
|
||||
const isPageRoute = matchPath("/p/:pageId");
|
||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||
|
||||
return (
|
||||
@ -25,23 +28,25 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
header={{ height: 45 }}
|
||||
navbar={{
|
||||
width: 300,
|
||||
breakpoint: 'sm',
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||
}}
|
||||
aside={{
|
||||
width: 300,
|
||||
breakpoint: 'md',
|
||||
collapsed: { mobile: (!isAsideOpen), desktop: (!isAsideOpen) },
|
||||
breakpoint: "md",
|
||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header
|
||||
className={classes.header}
|
||||
>
|
||||
|
||||
<AppShell.Header className={classes.header}>
|
||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||
|
||||
<Group h="100%" maw="60%" px="md" wrap="nowrap" style={{ overflow: 'hidden' }}>
|
||||
<Group
|
||||
h="100%"
|
||||
maw="60%"
|
||||
px="md"
|
||||
wrap="nowrap"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Burger
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
@ -58,31 +63,25 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
{isPageRoute && <Breadcrumb />}
|
||||
</Group>
|
||||
|
||||
{
|
||||
isPageRoute &&
|
||||
{isPageRoute && (
|
||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
||||
<Header />
|
||||
</Group>
|
||||
}
|
||||
)}
|
||||
</Group>
|
||||
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar>
|
||||
<Navbar />
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
|
||||
{
|
||||
isPageRoute &&
|
||||
{isPageRoute && (
|
||||
<AppShell.Aside className={classes.aside}>
|
||||
<Aside />
|
||||
</AppShell.Aside>
|
||||
}
|
||||
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
108
apps/client/src/components/layouts/settings/settings-sidebar.tsx
Normal file
108
apps/client/src/components/layouts/settings/settings-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -12,15 +12,13 @@ import {
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconFilePlus,
|
||||
IconHome
|
||||
IconHome,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import classes from './navbar.module.css';
|
||||
import { UserButton } from './user-button';
|
||||
import React from 'react';
|
||||
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 { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom';
|
||||
import PageTree from '@/features/page/tree/page-tree';
|
||||
@ -36,11 +34,10 @@ const primaryMenu: PrimaryMenuItem[] = [
|
||||
{ icon: IconHome, label: 'Home' },
|
||||
{ icon: IconSearch, label: 'Search' },
|
||||
{ icon: IconSettings, label: 'Settings' },
|
||||
// { icon: IconFilePlus, label: 'New Page' },
|
||||
// { icon: IconFilePlus, label: 'New Page' },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const [, setSettingsModalOpen] = useAtom(settingsModalAtom);
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@ -54,7 +51,7 @@ export function Navbar() {
|
||||
}
|
||||
|
||||
if (label === 'Settings') {
|
||||
setSettingsModalOpen(true);
|
||||
navigate('/settings/workspace');
|
||||
}
|
||||
};
|
||||
|
||||
@ -69,11 +66,7 @@ export function Navbar() {
|
||||
onClick={() => handleMenuItemClick(menuItem.label)}
|
||||
>
|
||||
<div className={classes.menuItemInner}>
|
||||
<menuItem.icon
|
||||
size={18}
|
||||
className={classes.menuItemIcon}
|
||||
stroke={2}
|
||||
/>
|
||||
<menuItem.icon size={18} className={classes.menuItemIcon} stroke={2} />
|
||||
<span>{menuItem.label}</span>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
@ -113,12 +106,10 @@ export function Navbar() {
|
||||
<div className={classes.pages}>
|
||||
<PageTree />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<SearchSpotlight />
|
||||
<SettingsModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,37 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Avatar } from '@mantine/core';
|
||||
import React from "react";
|
||||
import { Avatar } from "@mantine/core";
|
||||
|
||||
interface UserAvatarProps {
|
||||
avatarUrl: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
radius?: string;
|
||||
size?: string | number;
|
||||
radius?: string | number;
|
||||
style?: any;
|
||||
component?: any;
|
||||
}
|
||||
|
||||
export const UserAvatar = React.forwardRef<HTMLInputElement, UserAvatarProps>(
|
||||
({ avatarUrl, name, ...props }: UserAvatarProps, ref) => {
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
const names = name?.split(' ');
|
||||
return names?.slice(0, 2).map(n => n[0]).join('');
|
||||
const names = name?.split(" ");
|
||||
return names
|
||||
?.slice(0, 2)
|
||||
.map((n) => n[0])
|
||||
.join("");
|
||||
};
|
||||
|
||||
return (
|
||||
avatarUrl ? (
|
||||
<Avatar
|
||||
ref={ref}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
radius="xl"
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<Avatar ref={ref}
|
||||
{...props}>{getInitials(name)}</Avatar>
|
||||
)
|
||||
return avatarUrl ? (
|
||||
<Avatar ref={ref} src={avatarUrl} alt={name} radius="xl" {...props} />
|
||||
) : (
|
||||
<Avatar ref={ref} {...props}>
|
||||
{getInitials(name)}
|
||||
</Avatar>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
6
apps/client/src/features/auth/components/auth.module.css
Normal file
6
apps/client/src/features/auth/components/auth.module.css
Normal 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);
|
||||
}
|
||||
@ -15,6 +15,7 @@ import {
|
||||
PasswordInput,
|
||||
} from '@mantine/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
import classes from './auth.module.css';
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z
|
||||
@ -40,17 +41,11 @@ export function LoginForm() {
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title ta="center" fw={800}>
|
||||
Login
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||
Don't have an account yet?{' '}
|
||||
<Anchor size="sm" component={Link} to="/signup">
|
||||
Create account
|
||||
</Anchor>
|
||||
</Text>
|
||||
<Paper shadow="md" p="lg" radius="md" mt={200}>
|
||||
<Title ta="center" fw={800}>
|
||||
Login
|
||||
</Title>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="email"
|
||||
@ -72,6 +67,14 @@ export function LoginForm() {
|
||||
Sign In
|
||||
</Button>
|
||||
</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>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@ -50,7 +50,7 @@ export function SignUpForm() {
|
||||
</Anchor>
|
||||
</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)}>
|
||||
<TextInput
|
||||
id="email"
|
||||
|
||||
@ -78,7 +78,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
|
||||
<Stack gap={2}>
|
||||
<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 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={500} lineClamp={1}>{currentUser.user.name}</Text>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
apps/client/src/features/group/components/group-details.tsx
Normal file
33
apps/client/src/features/group/components/group-details.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
apps/client/src/features/group/components/group-list.tsx
Normal file
70
apps/client/src/features/group/components/group-list.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
apps/client/src/features/group/components/group-members.tsx
Normal file
102
apps/client/src/features/group/components/group-members.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
134
apps/client/src/features/group/queries/group-query.ts
Normal file
134
apps/client/src/features/group/queries/group-query.ts
Normal 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" });
|
||||
},
|
||||
});
|
||||
}
|
||||
46
apps/client/src/features/group/services/group-service.ts
Normal file
46
apps/client/src/features/group/services/group-service.ts
Normal 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);
|
||||
}
|
||||
12
apps/client/src/features/group/types/group.types.ts
Normal file
12
apps/client/src/features/group/types/group.types.ts
Normal 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;
|
||||
}
|
||||
@ -22,7 +22,7 @@ export default function HomeTabs() {
|
||||
|
||||
<Tabs.Panel value="recent">
|
||||
|
||||
<RecentChanges />
|
||||
{/* <RecentChanges /> */}
|
||||
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<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} />
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{historyItem.lastUpdatedBy.name}
|
||||
|
||||
@ -7,7 +7,7 @@ export async function createPage(data: Partial<IPage>): 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;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from 'react-arborist';
|
||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
||||
import {
|
||||
IconArrowsLeftRight,
|
||||
IconChevronDown,
|
||||
@ -11,24 +11,27 @@ import {
|
||||
IconPlus,
|
||||
IconStar,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import classes from './styles/tree.module.css';
|
||||
import { ActionIcon, Menu, rem } from '@mantine/core';
|
||||
import { useAtom } from 'jotai';
|
||||
import { FillFlexParent } from './components/fill-flex-parent';
|
||||
import { TreeNode } from './types';
|
||||
import { treeApiAtom } from './atoms/tree-api-atom';
|
||||
import { usePersistence } from '@/features/page/tree/hooks/use-persistence';
|
||||
import useWorkspacePageOrder from '@/features/page/tree/hooks/use-workspace-page-order';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { convertToTree, updateTreeNodeIcon } from '@/features/page/tree/utils';
|
||||
import { useGetPagesQuery, useUpdatePageMutation } from '@/features/page/queries/page-query';
|
||||
import EmojiPicker from '@/components/emoji-picker';
|
||||
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom';
|
||||
import classes from "./styles/tree.module.css";
|
||||
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { FillFlexParent } from "./components/fill-flex-parent";
|
||||
import { TreeNode } from "./types";
|
||||
import { treeApiAtom } from "./atoms/tree-api-atom";
|
||||
import { usePersistence } from "@/features/page/tree/hooks/use-persistence";
|
||||
import useWorkspacePageOrder from "@/features/page/tree/hooks/use-workspace-page-order";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { convertToTree, updateTreeNodeIcon } from "@/features/page/tree/utils";
|
||||
import {
|
||||
useGetPagesQuery,
|
||||
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() {
|
||||
const { data, setData, controllers } = usePersistence<TreeApi<TreeNode>>();
|
||||
@ -46,7 +49,7 @@ export default function PageTree() {
|
||||
setData(treeData);
|
||||
}
|
||||
} 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(() => {
|
||||
setTimeout(() => {
|
||||
tree?.select(pageId);
|
||||
tree?.scrollTo(pageId, 'center');
|
||||
tree?.scrollTo(pageId, "center");
|
||||
}, 200);
|
||||
}, [tree, pageId]);
|
||||
|
||||
@ -106,7 +109,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
const handleEmojiIconClick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
handleUpdateNodeIcon(node.id, emoji.native);
|
||||
@ -134,19 +137,25 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
>
|
||||
<PageArrow node={node} />
|
||||
|
||||
<div onClick={handleEmojiIconClick} style={{ marginRight: '4px' }}>
|
||||
<EmojiPicker onEmojiSelect={handleEmojiSelect} icon={
|
||||
node.data.icon ? node.data.icon :
|
||||
<IconFileDescription size="18px" />
|
||||
|
||||
} removeEmojiAction={handleRemoveEmoji}/>
|
||||
<div onClick={handleEmojiIconClick} style={{ marginRight: "4px" }}>
|
||||
<EmojiPicker
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
icon={
|
||||
node.data.icon ? (
|
||||
node.data.icon
|
||||
) : (
|
||||
<IconFileDescription size="18px" />
|
||||
)
|
||||
}
|
||||
removeEmojiAction={handleRemoveEmoji}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={classes.text}>
|
||||
{node.isEditing ? (
|
||||
<Input node={node} />
|
||||
) : (
|
||||
node.data.name || 'untitled'
|
||||
node.data.name || "untitled"
|
||||
)}
|
||||
</span>
|
||||
|
||||
@ -163,15 +172,19 @@ function CreateNode({ node }: { node: NodeApi<TreeNode> }) {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
|
||||
function handleCreate() {
|
||||
tree?.create({ type: 'internal', parentId: node.id, index: 0 });
|
||||
tree?.create({ type: "internal", parentId: node.id, index: 0 });
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionIcon variant="transparent" color="gray" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCreate();
|
||||
}}>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCreate();
|
||||
}}
|
||||
>
|
||||
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
);
|
||||
@ -187,10 +200,14 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" color="gray" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<IconDotsVertical
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={2}
|
||||
@ -240,7 +257,7 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
Archive
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
c="red"
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
@ -255,13 +272,16 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
|
||||
function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
||||
return (
|
||||
<ActionIcon size={20} variant="subtle" color="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
node.toggle();
|
||||
}}>
|
||||
|
||||
<ActionIcon
|
||||
size={20}
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
node.toggle();
|
||||
}}
|
||||
>
|
||||
{node.isInternal ? (
|
||||
node.children && node.children.length > 0 ? (
|
||||
node.isOpen ? (
|
||||
@ -270,7 +290,7 @@ function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
||||
<IconChevronRight stroke={2} size={18} />
|
||||
)
|
||||
) : (
|
||||
<IconChevronRight size={18} style={{ visibility: 'hidden' }} />
|
||||
<IconChevronRight size={18} style={{ visibility: "hidden" }} />
|
||||
)
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
@ -278,7 +298,6 @@ function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
||||
}
|
||||
|
||||
function Input({ node }: { node: NodeApi<TreeNode> }) {
|
||||
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
@ -289,10 +308,9 @@ function Input({ node }: { node: NodeApi<TreeNode> }) {
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => node.reset()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') node.reset();
|
||||
if (e.key === 'Enter') node.submit(e.currentTarget.value);
|
||||
if (e.key === "Escape") node.reset();
|
||||
if (e.key === "Enter") node.submit(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const settingsModalAtom = atom<boolean>(false);
|
||||
@ -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);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
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);
|
||||
|
||||
60
apps/client/src/features/user/components/account-avatar.tsx
Normal file
60
apps/client/src/features/user/components/account-avatar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
*/
|
||||
94
apps/client/src/features/user/components/change-email.tsx
Normal file
94
apps/client/src/features/user/components/change-email.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
apps/client/src/features/user/components/change-password.tsx
Normal file
84
apps/client/src/features/user/components/change-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
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({
|
||||
queryKey: ["currentUser"],
|
||||
queryFn: async () => {
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
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> {
|
||||
const req = await api.get<IUser>('/user/me');
|
||||
const req = await api.post<IUser>('/users/me');
|
||||
return req.data as IUser;
|
||||
}
|
||||
|
||||
export async function getUserInfo(): Promise<ICurrentUserResponse> {
|
||||
const req = await api.get<ICurrentUserResponse>('/user/info');
|
||||
return req.data as ICurrentUserResponse;
|
||||
export async function getUserInfo(): Promise<ICurrentUser> {
|
||||
const req = await api.post<ICurrentUser>('/users/info');
|
||||
return req.data as ICurrentUser;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -9,13 +9,13 @@ export interface IUser {
|
||||
timezone: string;
|
||||
settings: any;
|
||||
lastLoginAt: string;
|
||||
lastLoginIp: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
workspaceRole?: string;
|
||||
role: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface ICurrentUserResponse {
|
||||
export interface ICurrentUser {
|
||||
user: IUser,
|
||||
workspace: IWorkspace
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
enum UserRole {
|
||||
|
||||
@ -1,27 +1,27 @@
|
||||
import { WorkspaceInviteForm } from '@/features/settings/workspace/members/components/workspace-invite-form';
|
||||
import { Button, Divider, Modal, ScrollArea } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form.tsx";
|
||||
import { Button, Divider, Modal, ScrollArea } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
export default function WorkspaceInviteModal() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={open}>
|
||||
Invite members
|
||||
</Button>
|
||||
<Button onClick={open}>Invite members</Button>
|
||||
|
||||
<Modal size="600" opened={opened} onClose={close} title="Invite new members" centered>
|
||||
|
||||
<Divider size="xs" mb="xs"/>
|
||||
<Modal
|
||||
size="600"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title="Invite new members"
|
||||
centered
|
||||
>
|
||||
<Divider size="xs" mb="xs" />
|
||||
|
||||
<ScrollArea h="80%">
|
||||
|
||||
<WorkspaceInviteForm />
|
||||
|
||||
</ScrollArea>
|
||||
</Modal>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 React from "react";
|
||||
|
||||
|
||||
@ -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 * as z from "zod";
|
||||
import { useState } from "react";
|
||||
import { focusAtom } from "jotai-optics";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
import { TextInput, Button } from "@mantine/core";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
@ -1,19 +1,21 @@
|
||||
import api from '@/lib/api-client';
|
||||
import { ICurrentUserResponse, IUser } from '@/features/user/types/user.types';
|
||||
import { IWorkspace } from '../types/workspace.types';
|
||||
import api from "@/lib/api-client";
|
||||
import { IUser } from "@/features/user/types/user.types";
|
||||
import { IWorkspace } from "../types/workspace.types";
|
||||
import { QueryParams } from "@/lib/types.ts";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function getWorkspaceUsers(): Promise<IUser[]> {
|
||||
const req = await api.get<IUser[]>('/workspace/members');
|
||||
return req.data as IUser[];
|
||||
// Todo: fix all paginated types
|
||||
export async function getWorkspaceMembers(params?: QueryParams): Promise<any> {
|
||||
const req = await api.post<any>("/workspace/members", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -8,8 +8,6 @@ export interface IWorkspace {
|
||||
enableInvite: boolean;
|
||||
inviteCode: string;
|
||||
settings: any;
|
||||
creatorId: string;
|
||||
pageOrder?:[]
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@ -3,10 +3,11 @@ import Cookies from "js-cookie";
|
||||
import Routes from "@/lib/routes";
|
||||
|
||||
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 accessToken = tokenData && JSON.parse(tokenData)?.accessToken;
|
||||
|
||||
@ -15,23 +16,23 @@ api.interceptors.request.use(config => {
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
(response) => {
|
||||
return response.data;
|
||||
},
|
||||
error => {
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
// Handle unauthorized error
|
||||
if (window.location.pathname != Routes.AUTH.LOGIN){
|
||||
if (window.location.pathname != Routes.AUTH.LOGIN) {
|
||||
window.location.href = Routes.AUTH.LOGIN;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 403:
|
||||
// Handle forbidden error
|
||||
|
||||
5
apps/client/src/lib/types.ts
Normal file
5
apps/client/src/lib/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface QueryParams {
|
||||
query?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
@ -18,7 +18,7 @@ root.render(
|
||||
<MantineProvider theme={theme}>
|
||||
<ModalsProvider>
|
||||
<TanstackProvider>
|
||||
<Notifications position="top-center" limit={3} />
|
||||
<Notifications position="top-right" limit={3} />
|
||||
<App />
|
||||
</TanstackProvider>
|
||||
</ModalsProvider>
|
||||
|
||||
26
apps/client/src/pages/settings/account/account-settings.tsx
Normal file
26
apps/client/src/pages/settings/account/account-settings.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
apps/client/src/pages/settings/group/group-info.tsx
Normal file
13
apps/client/src/pages/settings/group/group-info.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
apps/client/src/pages/settings/group/groups.tsx
Normal file
18
apps/client/src/pages/settings/group/groups.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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({
|
||||
|
||||
colors: {
|
||||
blue,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user