mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 19:32:37 +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 { 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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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 { 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>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
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';
|
||||||
@ -40,7 +38,6 @@ const primaryMenu: PrimaryMenuItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
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 />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
|
||||||
src={avatarUrl}
|
|
||||||
alt={name}
|
|
||||||
radius="xl"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Avatar ref={ref}
|
<Avatar ref={ref} {...props}>
|
||||||
{...props}>{getInitials(name)}</Avatar>
|
{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,
|
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}>
|
||||||
|
<Paper shadow="md" p="lg" radius="md" mt={200}>
|
||||||
<Title ta="center" fw={800}>
|
<Title ta="center" fw={800}>
|
||||||
Login
|
Login
|
||||||
</Title>
|
</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 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
<Tabs.Panel value="recent">
|
||||||
|
|
||||||
<RecentChanges />
|
{/* <RecentChanges /> */}
|
||||||
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
icon={
|
||||||
|
node.data.icon ? (
|
||||||
|
node.data.icon
|
||||||
|
) : (
|
||||||
<IconFileDescription size="18px" />
|
<IconFileDescription size="18px" />
|
||||||
|
)
|
||||||
} removeEmojiAction={handleRemoveEmoji}/>
|
}
|
||||||
|
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
|
||||||
|
variant="transparent"
|
||||||
|
c="gray"
|
||||||
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleCreate();
|
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
|
||||||
|
variant="transparent"
|
||||||
|
c="gray"
|
||||||
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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
|
||||||
|
size={20}
|
||||||
|
variant="subtle"
|
||||||
|
c="gray"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
node.toggle();
|
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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { 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 { 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 () => {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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"
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title="Invite new members"
|
||||||
|
centered
|
||||||
|
>
|
||||||
<Divider size="xs" mb="xs" />
|
<Divider size="xs" mb="xs" />
|
||||||
|
|
||||||
<ScrollArea h="80%">
|
<ScrollArea h="80%">
|
||||||
|
|
||||||
<WorkspaceInviteForm />
|
<WorkspaceInviteForm />
|
||||||
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,16 +16,16 @@ 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:
|
||||||
|
|||||||
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}>
|
<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>
|
||||||
|
|||||||
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({
|
export const theme = createTheme({
|
||||||
|
colors: {
|
||||||
|
blue,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user