Merge pull request #13 from docmost/layout-refactor

Layout refactor
This commit is contained in:
Philip Okugbe 2024-06-01 14:12:19 +01:00 committed by GitHub
commit b88e0b605f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
95 changed files with 1572 additions and 823 deletions

View File

@ -2,12 +2,10 @@ import { Route, Routes } from "react-router-dom";
import { Welcome } from "@/pages/welcome";
import SignUpPage from "@/pages/auth/signup";
import LoginPage from "@/pages/auth/login";
import DashboardLayout from "@/components/layouts/dashboard/dashboard-layout.tsx";
import Home from "@/pages/dashboard/home";
import Page from "@/pages/page/page";
import AccountSettings from "@/pages/settings/account/account-settings";
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
import SettingsLayout from "@/components/layouts/settings/settings-layout.tsx";
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
import Groups from "@/pages/settings/group/groups";
import GroupInfo from "./pages/settings/group/group-info";
@ -23,6 +21,9 @@ import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts";
import { SOCKET_URL } from "@/features/websocket/types";
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
import SpaceHome from "@/pages/space/space-home.tsx";
import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
export default function App() {
const [, setSocket] = useAtom(socketAtom);
@ -63,23 +64,28 @@ export default function App() {
<Route path={"/signup"} element={<SignUpPage />} />
<Route path={"/invites/:invitationId"} element={<InviteSignUpForm />} />
<Route element={<DashboardLayout />}>
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route element={<Layout />}>
<Route path={"/home"} element={<Home />} />
<Route path={"/p/:slugId/:slug?"} element={<Page />} />
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route path={"/s/:spaceSlug/p/:pageSlug"} element={<Page />} />
<Route path={"/settings"}>
<Route path={"account/profile"} element={<AccountSettings />} />
<Route
path={"account/preferences"}
element={<AccountPreferences />}
/>
<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={<Spaces />} />
</Route>
</Route>
<Route path={"/settings"} element={<SettingsLayout />}>
<Route path={"account/profile"} element={<AccountSettings />} />
<Route
path={"account/preferences"}
element={<AccountPreferences />}
/>
<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={<Spaces />} />
</Route>
<Route path="*" element={<Error404 />} />
</Routes>
</>

View File

@ -2,10 +2,6 @@ import { ActionIcon, rem } from "@mantine/core";
import React from "react";
import { IconUsersGroup } from "@tabler/icons-react";
interface IconPeopleCircleProps extends React.ComponentPropsWithoutRef<"svg"> {
size?: number | string;
}
export function IconGroupCircle() {
return (
<ActionIcon variant="light" size="lg" color="gray" radius="xl">

View File

@ -1,6 +0,0 @@
.breadcrumb {
a {
color: var(--mantine-color-default-color);
text-overflow: ellipsis;
}
}

View File

@ -1,17 +0,0 @@
import { UserProvider } from "@/features/user/user-provider.tsx";
import Shell from "./shell.tsx";
import { Outlet } from "react-router-dom";
import { Helmet } from "react-helmet-async";
export default function DashboardLayout() {
return (
<UserProvider>
<Shell>
<Helmet>
<title>Home</title>
</Helmet>
<Outlet />
</Shell>
</UserProvider>
);
}

View File

@ -1,38 +0,0 @@
.header,
.footer {
@media (max-width: 992px) {
[data-layout="alt"] & {
--_section-right: var(--app-shell-aside-offset, 0px);
}
}
}
.aside {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
@media (min-width: 993px) {
[data-layout="alt"] & {
--_section-top: var(--_section-top, var(--app-shell-header-offset, 0px));
--_section-height: var(
--_section-height,
calc(
100dvh - var(--app-shell-header-offset, 0px) -
var(--app-shell-footer-offset, 0px)
)
);
}
}
}
@media (max-width: 48em) {
.aside {
width: 350px;
}
}
@media (max-width: 48em) {
.navbar {
width: 300px;
}
}

View File

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

View File

@ -0,0 +1,23 @@
.header {
height: 100%;
margin-bottom: rem(120px);
background-color: var(--mantine-color-body);
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
}
.link {
display: block;
line-height: 1;
padding: rem(8px) rem(12px);
border-radius: var(--mantine-radius-sm);
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
font-size: var(--mantine-font-size-sm);
font-weight: 500;
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
}

View File

@ -0,0 +1,70 @@
import { Group } from "@mantine/core";
import { IconSquareLetterDFilled } from "@tabler/icons-react";
import classes from "./app-header.module.css";
import React from "react";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useAtom } from "jotai/index";
import {
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
export function AppHeader() {
const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const isHomeRoute = location.pathname.startsWith("/home");
const items = links.map((link) => (
<Link key={link.label} to={link.link} className={classes.link}>
{link.label}
</Link>
));
return (
<>
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
<Group>
<IconSquareLetterDFilled size={30} />
{!isHomeRoute && (
<>
<SidebarToggle
aria-label="sidebar toggle"
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<SidebarToggle
aria-label="sidebar toggle"
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</>
)}
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
{items}
</Group>
</Group>
<Group px={"xl"}>
<TopMenu />
</Group>
</Group>
</>
);
}

View File

@ -0,0 +1,16 @@
.header, .navbar, .aside {
background-color: light-dark(#f6f7f9, var(--mantine-color-dark-8));
}
.navbar, .aside {
@media (max-width: $mantine-breakpoint-sm) {
width: 350px;
}
}
.aside {
@media (max-width: $mantine-breakpoint-sm) {
margin-top: 45px;
}
}

View File

@ -1,7 +1,7 @@
import { Box, ScrollArea, Text } from "@mantine/core";
import CommentList from "@/features/comment/components/comment-list.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/navbar/atoms/sidebar-atom.ts";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode } from "react";
export default function Aside() {

View File

@ -0,0 +1,77 @@
import { AppShell, Container } from "@mantine/core";
import React from "react";
import { useLocation } from "react-router-dom";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai";
import {
asideStateAtom,
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css";
export default function GlobalAppShell({
children,
}: {
children: React.ReactNode;
}) {
const [mobileOpened] = useAtom(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen }] = useAtom(asideStateAtom);
const location = useLocation();
const isSettingsRoute = location.pathname.startsWith("/settings");
const isSpaceRoute = location.pathname.startsWith("/s/");
const isHomeRoute = location.pathname.startsWith("/home");
const isPageRoute = location.pathname.includes("/p/");
return (
<AppShell
header={{ height: 45 }}
navbar={
!isHomeRoute && {
width: 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
},
}
}
aside={
isPageRoute && {
width: 350,
breakpoint: "sm",
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
}
}
padding="md"
>
<AppShell.Header px="md" className={classes.header}>
<AppHeader />
</AppShell.Header>
{!isHomeRoute && (
<AppShell.Navbar className={classes.navbar} withBorder={false}>
{isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />}
</AppShell.Navbar>
)}
<AppShell.Main>
{isSettingsRoute ? (
<Container size={800}>{children}</Container>
) : (
children
)}
</AppShell.Main>
{isPageRoute && (
<AppShell.Aside className={classes.aside} p="md" withBorder={false}>
<Aside />
</AppShell.Aside>
)}
</AppShell>
);
}

View File

@ -0,0 +1,21 @@
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
import { atom } from "jotai";
export const mobileSidebarAtom = atom<boolean>(false);
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
"showSidebar",
true,
);
export const desktopAsideAtom = atom<boolean>(false);
type AsideStateType = {
tab: string;
isAsideOpen: boolean;
};
export const asideStateAtom = atom<AsideStateType>({
tab: "",
isAsideOpen: false,
});

View File

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

View File

@ -0,0 +1,124 @@
import { Avatar, Group, Menu, rem, UnstyledButton, Text } from "@mantine/core";
import {
IconChevronDown,
IconLogout,
IconSettings,
IconUserCircle,
IconUsers,
} from "@tabler/icons-react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import useAuth from "@/features/auth/hooks/use-auth.ts";
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
export default function TopMenu() {
const [currentUser] = useAtom(currentUserAtom);
const { logout } = useAuth();
const user = currentUser?.user;
const workspace = currentUser?.workspace;
return (
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
<Menu.Target>
<UnstyledButton>
<Group gap={7} wrap={"nowrap"}>
<Avatar
src={workspace.logo}
alt={workspace.name}
radius="xl"
size={20}
/>
<Text fw={500} size="sm" lh={1} mr={3}>
{workspace.name}
</Text>
<IconChevronDown
style={{ width: rem(12), height: rem(12) }}
stroke={1.5}
/>
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Workspace</Menu.Label>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
leftSection={
<IconSettings
style={{ width: rem(16), height: rem(16) }}
stroke={1.5}
/>
}
>
Workspace settings
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
leftSection={
<IconUsers
style={{ width: rem(16), height: rem(16) }}
stroke={1.5}
/>
}
>
Manage members
</Menu.Item>
<Menu.Divider />
<Menu.Label>Account</Menu.Label>
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
<Group wrap={"nowrap"}>
<UserAvatar
radius="xl"
size={"sm"}
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div>
<Text size="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text size="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
leftSection={
<IconUserCircle
style={{ width: rem(16), height: rem(16) }}
stroke={1.5}
/>
}
>
My profile
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={logout}
leftSection={
<IconLogout
style={{ width: rem(16), height: rem(16) }}
stroke={1.5}
/>
}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}

View File

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

View File

@ -1,36 +0,0 @@
import { desktopSidebarAtom } from "@/components/navbar/atoms/sidebar-atom.ts";
import { AppShell, Container } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useAtom } from "jotai";
import React from "react";
import SettingsSidebar from "@/components/layouts/settings/settings-sidebar.tsx";
export default function SettingsShell({
children,
}: {
children: React.ReactNode;
}) {
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
const [desktopOpened] = useAtom(desktopSidebarAtom);
return (
<AppShell
layout="alt"
header={{ height: 45 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
padding="md"
>
<AppShell.Navbar>
<SettingsSidebar />
</AppShell.Navbar>
<AppShell.Main>
<Container size={800}>{children}</Container>
</AppShell.Main>
</AppShell>
);
}

View File

@ -1,16 +0,0 @@
import { atomWithWebStorage } from '@/lib/jotai-helper';
import { atom } from 'jotai';
export const desktopSidebarAtom = atomWithWebStorage('showSidebar', true);
export const desktopAsideAtom = atom(false);
type AsideStateType = {
tab: string,
isAsideOpen: boolean,
}
export const asideStateAtom = atom<AsideStateType>({
tab: '',
isAsideOpen: false,
});

View File

@ -1,122 +0,0 @@
import {
UnstyledButton,
Text,
Group,
ActionIcon,
Tooltip,
rem,
} from "@mantine/core";
import { spotlight } from "@mantine/spotlight";
import {
IconSearch,
IconPlus,
IconSettings,
IconHome,
} from "@tabler/icons-react";
import classes from "./navbar.module.css";
import { UserButton } from "./user-button";
import React from "react";
import { useAtom } from "jotai";
import { SearchSpotlight } from "@/features/search/search-spotlight";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom";
import { useLocation, useNavigate } from "react-router-dom";
import SpaceContent from "@/features/page/tree/components/space-content.tsx";
import clsx from "clsx";
import APP_ROUTE from "@/lib/app-route.ts";
interface PrimaryMenuItem {
icon: React.ElementType;
label: string;
path?: string;
onClick?: () => void;
}
const primaryMenu: PrimaryMenuItem[] = [
{ icon: IconHome, label: "Home", path: "/home" },
{ icon: IconSearch, label: "Search" },
{ icon: IconSettings, label: "Settings" },
];
export function Navbar() {
const [tree] = useAtom(treeApiAtom);
const navigate = useNavigate();
const location = useLocation();
const handleMenuItemClick = (label: string) => {
if (label === "Home") {
navigate("/home");
}
if (label === "Search") {
spotlight.open();
}
if (label === "Settings") {
navigate("/settings/workspace");
}
};
function handleCreatePage() {
tree?.create({ parentId: null, type: "internal", index: 0 });
}
const primaryMenuItems = primaryMenu.map((menuItem) => (
<UnstyledButton
key={menuItem.label}
className={clsx(
classes.menu,
location.pathname.toLowerCase() === menuItem?.path
? classes.activeButton
: "",
)}
onClick={() => handleMenuItemClick(menuItem.label)}
>
<div className={classes.menuItemInner}>
<menuItem.icon size={18} className={classes.menuItemIcon} stroke={2} />
<span>{menuItem.label}</span>
</div>
</UnstyledButton>
));
return (
<>
<nav className={classes.navbar}>
<div className={classes.section}>
<UserButton />
</div>
<div className={classes.section}>
<div className={classes.menuItems}>{primaryMenuItems}</div>
</div>
<div className={classes.section}>
<Group className={classes.pagesHeader} justify="space-between">
<Text size="xs" fw={500} c="dimmed">
Pages
</Text>
<Tooltip label="Create page" withArrow position="right">
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
>
<IconPlus
style={{ width: rem(12), height: rem(12) }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
</Group>
<div className={classes.pages}>
<SpaceContent />
</div>
</div>
</nav>
<SearchSpotlight />
</>
);
}

View File

@ -1,32 +0,0 @@
import { UnstyledButton, Group, Avatar, Text, rem } from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import classes from './user-button.module.css';
import { useAtom } from 'jotai/index';
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
export function UserButton() {
const [currentUser] = useAtom(currentUserAtom);
return (
<UnstyledButton className={classes.user}>
<Group>
<Avatar
src="https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-9.png"
radius="xl"
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{currentUser?.user.name}
</Text>
<Text c="dimmed" size="xs">
{currentUser?.user.email}
</Text>
</div>
<IconChevronRight style={{ width: rem(14), height: rem(14) }} stroke={1.5} />
</Group>
</UnstyledButton>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Group, Text, ScrollArea, ActionIcon, rem } from "@mantine/core";
import {
IconUser,
@ -9,7 +9,7 @@ import {
IconSpaces,
IconBrush,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import classes from "./settings.module.css";
interface DataItem {
@ -51,8 +51,13 @@ const groupedData: DataGroup[] = [
];
export default function SettingsSidebar() {
const pathname = useLocation().pathname;
const [active, setActive] = useState(pathname);
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const navigate = useNavigate();
useEffect(() => {
setActive(location.pathname);
}, [location.pathname]);
const menuItems = groupedData.map((group) => (
<div key={group.heading}>
@ -65,9 +70,6 @@ export default function SettingsSidebar() {
data-active={active.startsWith(item.path) || undefined}
key={item.label}
to={item.path}
onClick={() => {
setActive(item.path);
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{item.label}</span>
@ -77,32 +79,20 @@ export default function SettingsSidebar() {
));
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>
<div className={classes.navbar}>
<Group className={classes.title} justify="flex-start">
<ActionIcon
onClick={() => navigate(-1)}
variant="transparent"
c="gray"
aria-label="Back"
>
<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>
<ScrollArea w="100%">{menuItems}</ScrollArea>
</div>
);
}

View File

@ -1,29 +1,16 @@
.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);
.title {
padding-bottom: var(--mantine-spacing-sm);
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;

View File

@ -0,0 +1,34 @@
import {
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
} from "@tabler/icons-react";
import {
ActionIcon,
BoxProps,
ElementProps,
MantineColor,
MantineSize,
} from "@mantine/core";
import React from "react";
export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
size?: MantineSize | `compact-${MantineSize}` | (string & {});
color?: MantineColor;
opened?: boolean;
}
export default function SidebarToggle({
opened,
size = "sm",
...others
}: SidebarToggleProps) {
return (
<ActionIcon size={size} {...others} variant="subtle" color="gray">
{opened ? (
<IconLayoutSidebarRightExpand />
) : (
<IconLayoutSidebarRightCollapse />
)}
</ActionIcon>
);
}

View File

@ -18,6 +18,7 @@ import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
import Cookies from "js-cookie";
import { jwtDecode } from "jwt-decode";
import APP_ROUTE from "@/lib/app-route.ts";
export default function useAuth() {
const [isLoading, setIsLoading] = useState(false);
@ -34,7 +35,7 @@ export default function useAuth() {
setIsLoading(false);
setAuthToken(res.tokens);
navigate("/home");
navigate(APP_ROUTE.HOME);
} catch (err) {
console.log(err);
setIsLoading(false);
@ -54,7 +55,7 @@ export default function useAuth() {
setAuthToken(res.tokens);
navigate("/home");
navigate(APP_ROUTE.HOME);
} catch (err) {
setIsLoading(false);
notifications.show({
@ -74,7 +75,7 @@ export default function useAuth() {
console.log(res);
setAuthToken(res.tokens);
navigate("/home");
navigate(APP_ROUTE.HOME);
} catch (err) {
setIsLoading(false);
notifications.show({
@ -93,7 +94,7 @@ export default function useAuth() {
setAuthToken(res.tokens);
navigate("/home");
navigate(APP_ROUTE.HOME);
} catch (err) {
setIsLoading(false);
notifications.show({
@ -129,7 +130,7 @@ export default function useAuth() {
setAuthToken(null);
setCurrentUser(null);
Cookies.remove("authTokens");
navigate("/login");
navigate(APP_ROUTE.AUTH.LOGIN);
};
return {

View File

@ -11,7 +11,7 @@ import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
import { asideStateAtom } from "@/components/navbar/atoms/sidebar-atom";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { useEditor } from "@tiptap/react";
interface CommentDialogProps {

View File

@ -13,10 +13,11 @@ import { useFocusWithin } from "@mantine/hooks";
import { IComment } from "@/features/comment/types/comment.types.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
function CommentList() {
const { slugId } = useParams();
const { data: page } = usePageQuery(slugId);
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const {
data: comments,
isLoading: isCommentsLoading,

View File

@ -10,12 +10,23 @@ export interface FullEditorProps {
pageId: string;
slugId: string;
title: string;
spaceSlug: string;
}
export function FullEditor({ pageId, title, slugId }: FullEditorProps) {
export function FullEditor({
pageId,
title,
slugId,
spaceSlug,
}: FullEditorProps) {
return (
<div className={classes.editor}>
<MemoizedTitleEditor pageId={pageId} slugId={slugId} title={title} />
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
/>
<MemoizedPageEditor pageId={pageId} />
</div>
);

View File

@ -13,7 +13,7 @@ import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import { asideStateAtom } from "@/components/navbar/atoms/sidebar-atom";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
activeCommentIdAtom,
showCommentPopupAtom,

View File

@ -20,16 +20,22 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { updateTreeNodeName } from "@/features/page/tree/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history";
import { buildPageSlug } from "@/features/page/page.utils.ts";
import { useNavigate } from "react-router-dom";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate, useParams } from "react-router-dom";
export interface TitleEditorProps {
pageId: string;
slugId: string;
title: string;
spaceSlug: string;
}
export function TitleEditor({ pageId, slugId, title }: TitleEditorProps) {
export function TitleEditor({
pageId,
slugId,
title,
spaceSlug,
}: TitleEditorProps) {
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
const updatePageMutation = useUpdatePageMutation();
@ -37,6 +43,7 @@ export function TitleEditor({ pageId, slugId, title }: TitleEditorProps) {
const [, setTitleEditor] = useAtom(titleEditorAtom);
const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
const navigate = useNavigate();
const titleEditor = useEditor({
@ -69,7 +76,7 @@ export function TitleEditor({ pageId, slugId, title }: TitleEditorProps) {
});
useEffect(() => {
const pageSlug = buildPageSlug(slugId, title);
const pageSlug = buildPageUrl(spaceSlug, slugId, title);
navigate(pageSlug, { replace: true });
}, [title]);

View File

@ -1,10 +1,19 @@
import { Text, Group, Stack, UnstyledButton, Divider } from "@mantine/core";
import {
Text,
Group,
Stack,
UnstyledButton,
Divider,
Badge,
} from "@mantine/core";
import classes from "./home.module.css";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/features/home/components/page-list-skeleton";
import { useRecentChangesQuery } from "@/features/page/queries/page-query";
import { buildPageSlug } from "@/features/page/page.utils.ts";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription } from "@tabler/icons-react";
import { getSpaceUrl } from "@/lib/config.ts";
function RecentChanges() {
const { data, isLoading, isError } = useRecentChangesQuery();
@ -18,31 +27,46 @@ function RecentChanges() {
}
return (
<div>
{data.items.map((page) => (
<div key={page.id}>
<UnstyledButton
component={Link}
to={buildPageSlug(page.slugId, page.title)}
className={classes.page}
p="xs"
>
<Group wrap="nowrap">
<Stack gap="xs" style={{ flex: 1 }}>
<Text fw={500} size="md" lineClamp={1}>
{page.title || "Untitled"}
</Text>
</Stack>
data && (
<div>
{data.items.map((page) => (
<div key={page.id}>
<UnstyledButton
component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
className={classes.page}
p="xs"
>
<Group wrap="nowrap">
<Stack gap="xs" style={{ flex: 1 }}>
<Group wrap="nowrap">
{page.icon || <IconFileDescription size={18} />}
<Text c="dimmed" size="xs" fw={500}>
{formattedDate(page.updatedAt)}
</Text>
</Group>
</UnstyledButton>
<Divider />
</div>
))}
</div>
<Text fw={500} size="md" lineClamp={1}>
{page.title || "Untitled"}
</Text>
</Group>
</Stack>
<Badge
color="blue"
variant="light"
component={Link}
to={getSpaceUrl(page.space.slug)}
>
{page.space.name}
</Badge>
<Text c="dimmed" size="xs" fw={500}>
{formattedDate(page.updatedAt)}
</Text>
</Group>
</UnstyledButton>
<Divider />
</div>
))}
</div>
)
);
}

View File

@ -0,0 +1,23 @@
.breadcrumbs {
flex: 1 1 auto;
display: flex;
align-items: center;
overflow: hidden;
a {
color: var(--mantine-color-default-color);
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
overflow: hidden;
}
}
.truncatedText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}

View File

@ -1,5 +1,5 @@
import { useAtomValue } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import React, { useEffect, useState } from "react";
import { findBreadcrumbPath } from "@/features/page/tree/utils";
import {
@ -14,8 +14,9 @@ import { IconDots } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import classes from "./breadcrumb.module.css";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { buildPageSlug } from "@/features/page/page.utils.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
function getTitle(name: string, icon: string) {
if (icon) {
@ -29,15 +30,15 @@ export default function Breadcrumb() {
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
SpaceTreeNode[] | null
>(null);
const { slugId } = useParams();
const { data: currentPage } = usePageQuery(slugId);
const { pageSlug, spaceSlug } = useParams();
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
useEffect(() => {
if (treeData?.length > 0 && currentPage) {
const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
if (breadcrumb) {
setBreadcrumbNodes(breadcrumb);
}
setBreadcrumbNodes(breadcrumb || null);
}
}, [currentPage?.id, treeData]);
@ -47,29 +48,40 @@ export default function Breadcrumb() {
<Button
justify="start"
component={Link}
to={buildPageSlug(node.slugId, node.name)}
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
variant="default"
style={{ border: "none" }}
>
<Text truncate="end">{getTitle(node.name, node.icon)}</Text>
<Text fz={"sm"} className={classes.truncatedText}>
{getTitle(node.name, node.icon)}
</Text>
</Button>
</Button.Group>
));
const getLastNthNode = (n: number) =>
breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
const renderAnchor = (node: SpaceTreeNode) => (
<Anchor
component={Link}
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
underline="never"
fz={"sm"}
key={node.id}
className={classes.truncatedText}
>
{getTitle(node.name, node.icon)}
</Anchor>
);
const getBreadcrumbItems = () => {
if (breadcrumbNodes?.length > 3) {
if (!breadcrumbNodes) return [];
if (breadcrumbNodes.length > 3) {
const firstNode = breadcrumbNodes[0];
const secondLastNode = breadcrumbNodes[breadcrumbNodes.length - 2];
const lastNode = breadcrumbNodes[breadcrumbNodes.length - 1];
return [
<Anchor
component={Link}
to={buildPageSlug(breadcrumbNodes[0].slugId, breadcrumbNodes[0].name)}
underline="never"
key={breadcrumbNodes[0].slugId}
>
{getTitle(breadcrumbNodes[0].name, breadcrumbNodes[0].icon)}
</Anchor>,
renderAnchor(firstNode),
<Popover
width={250}
position="bottom"
@ -78,7 +90,7 @@ export default function Breadcrumb() {
key="hidden-nodes"
>
<Popover.Target>
<ActionIcon c="gray" variant="transparent">
<ActionIcon color="gray" variant="transparent">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Popover.Target>
@ -86,47 +98,20 @@ export default function Breadcrumb() {
<HiddenNodesTooltipContent />
</Popover.Dropdown>
</Popover>,
<Anchor
component={Link}
to={buildPageSlug(getLastNthNode(2)?.slugId, getLastNthNode(2)?.name)}
underline="never"
key={getLastNthNode(2)?.slugId}
>
{getTitle(getLastNthNode(2)?.name, getLastNthNode(2)?.icon)}
</Anchor>,
<Anchor
component={Link}
to={buildPageSlug(getLastNthNode(1)?.slugId, getLastNthNode(1)?.name)}
underline="never"
key={getLastNthNode(1)?.slugId}
>
{getTitle(getLastNthNode(1)?.name, getLastNthNode(1)?.icon)}
</Anchor>,
renderAnchor(secondLastNode),
renderAnchor(lastNode),
];
}
if (breadcrumbNodes) {
return breadcrumbNodes.map((node) => (
<Anchor
component={Link}
to={buildPageSlug(node.slugId, node.name)}
underline="never"
key={node.id}
>
{getTitle(node.name, node.icon)}
</Anchor>
));
}
return [];
return breadcrumbNodes.map(renderAnchor);
};
return (
<div className={classes.breadcrumb}>
{breadcrumbNodes ? (
<Breadcrumbs>{getBreadcrumbItems()}</Breadcrumbs>
) : (
<></>
<div style={{ overflow: "hidden" }}>
{breadcrumbNodes && (
<Breadcrumbs className={classes.breadcrumbs}>
{getBreadcrumbItems()}
</Breadcrumbs>
)}
</div>
);

View File

@ -4,6 +4,7 @@ import {
IconHistory,
IconLink,
IconMessage,
IconTrash,
} from "@tabler/icons-react";
import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
@ -12,20 +13,18 @@ import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useClipboard } from "@mantine/hooks";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageSlug } from "@/features/page/page.utils.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
export default function Header() {
export default function PageHeaderMenu() {
const toggleAside = useToggleAside();
return (
<>
{/*
<Button variant="default" style={{ border: "none" }} size="compact-sm">
Share
</Button>
*/}
<Tooltip label="Comments" openDelay={250} withArrow>
<ActionIcon
variant="default"
@ -44,13 +43,18 @@ export default function Header() {
function PageActionMenu() {
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const clipboard = useClipboard({ timeout: 500 });
const { slugId } = useParams();
const { data: page, isLoading, isError } = usePageQuery(slugId);
const { pageSlug, spaceSlug } = useParams();
const { data: page, isLoading } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
const { openDeleteModal } = useDeletePageModal();
const [tree] = useAtom(treeApiAtom);
const handleCopyLink = () => {
const pageLink =
window.location.host + buildPageSlug(page.slugId, page.title);
clipboard.copy(pageLink);
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, page.slugId, page.title);
clipboard.copy(pageUrl);
notifications.show({ message: "Link copied" });
};
@ -58,6 +62,10 @@ function PageActionMenu() {
setHistoryModalOpen(true);
};
const handleDeletePage = () => {
openDeleteModal({ onConfirm: () => tree?.delete(page.id) });
};
return (
<Menu
shadow="xl"
@ -88,12 +96,14 @@ function PageActionMenu() {
Page history
</Menu.Item>
{/*
<Menu.Divider />
<Menu.Item leftSection={<IconTrash size={16} stroke={2} />}>
<Menu.Item
color={"red"}
leftSection={<IconTrash size={16} stroke={2} />}
onClick={handleDeletePage}
>
Delete
</Menu.Item>
*/}
</Menu.Dropdown>
</Menu>
);

View File

@ -0,0 +1,11 @@
.header {
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
}

View File

@ -0,0 +1,18 @@
import classes from "./page-header.module.css";
import PageHeaderMenu from "@/features/page/components/header/page-header-menu.tsx";
import { Group } from "@mantine/core";
import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx";
export default function PageHeader() {
return (
<div className={classes.header}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
<Breadcrumb />
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<PageHeaderMenu />
</Group>
</Group>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
type UseDeleteModalProps = {
onConfirm: () => void;
};
export function useDeletePageModal() {
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
modals.openConfirmModal({
title: "Are you sure you want to delete this page?",
children: (
<Text size="sm">
Are you sure you want to delete this page? This will delete its
children and page history. This action is irreversible.
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm,
});
};
return { openDeleteModal } as const;
}

View File

@ -1,15 +1,23 @@
import slugify from "@sindresorhus/slugify";
export const buildPageSlug = (
pageShortId: string,
pageTitle?: string,
): string => {
const titleSlug = slugify(pageTitle?.substring(0, 99) || "untitled", {
const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", {
customReplacements: [
["♥", ""],
["🦄", ""],
],
});
return `/p/${pageShortId}/${titleSlug}`;
return `p/${titleSlug}-${pageSlugId}`;
};
export const buildPageUrl = (
spaceName: string,
pageSlugId: string,
pageTitle?: string,
): string => {
if (spaceName === undefined) {
return `/${buildPageSlug(pageSlugId, pageTitle)}`;
}
return `/s/${spaceName}/${buildPageSlug(pageSlugId, pageTitle)}`;
};

View File

@ -10,14 +10,15 @@ import {
deletePage,
getPageById,
getSidebarPages,
getRecentChanges,
updatePage,
movePage,
getPageBreadcrumbs,
getRecentChanges,
} from "@/features/page/services/page-service";
import {
IMovePage,
IPage,
IPageInput,
SidebarPagesParams,
} from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
@ -25,32 +26,19 @@ import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
const RECENT_CHANGES_KEY = ["recentChanges"];
export function usePageQuery(
pageIdOrSlugId: string,
pageInput: Partial<IPageInput>,
): UseQueryResult<IPage, Error> {
return useQuery({
queryKey: ["pages", pageIdOrSlugId],
queryFn: () => getPageById(pageIdOrSlugId),
enabled: !!pageIdOrSlugId,
queryKey: ["pages", pageInput.pageId],
queryFn: () => getPageById(pageInput),
enabled: !!pageInput.pageId,
staleTime: 5 * 60 * 1000,
});
}
export function useRecentChangesQuery(): UseQueryResult<
IPagination<IPage>,
Error
> {
return useQuery({
queryKey: RECENT_CHANGES_KEY,
queryFn: () => getRecentChanges(),
refetchOnMount: true,
});
}
export function useCreatePageMutation() {
return useMutation<IPage, Error, Partial<IPage>>({
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => createPage(data),
onSuccess: (data) => {},
onError: (error) => {
@ -61,7 +49,7 @@ export function useCreatePageMutation() {
export function useUpdatePageMutation() {
const queryClient = useQueryClient();
return useMutation<IPage, Error, Partial<IPage>>({
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data),
onSuccess: (data) => {
// update page in cache
@ -130,3 +118,13 @@ export async function fetchAncestorChildren(params: SidebarPagesParams) {
});
return buildTree(response.items);
}
export function useRecentChangesQuery(
spaceId?: string,
): UseQueryResult<IPagination<IPage>, Error> {
return useQuery({
queryKey: ["recent-changes", spaceId],
queryFn: () => getRecentChanges(spaceId),
refetchOnMount: true,
});
}

View File

@ -2,6 +2,7 @@ import api from "@/lib/api-client";
import {
IMovePage,
IPage,
IPageInput,
SidebarPagesParams,
} from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts";
@ -11,12 +12,14 @@ export async function createPage(data: Partial<IPage>): Promise<IPage> {
return req.data;
}
export async function getPageById(pageId: string): Promise<IPage> {
const req = await api.post<IPage>("/pages/info", { pageId });
export async function getPageById(
pageInput: Partial<IPageInput>,
): Promise<IPage> {
const req = await api.post<IPage>("/pages/info", pageInput);
return req.data;
}
export async function updatePage(data: Partial<IPage>): Promise<IPage> {
export async function updatePage(data: Partial<IPageInput>): Promise<IPage> {
const req = await api.post<IPage>("/pages/update", data);
return req.data;
}
@ -29,11 +32,6 @@ export async function movePage(data: IMovePage): Promise<void> {
await api.post<void>("/pages/move", data);
}
export async function getRecentChanges(): Promise<IPagination<IPage>> {
const req = await api.post("/pages/recent");
return req.data;
}
export async function getSidebarPages(
params: SidebarPagesParams,
): Promise<IPagination<IPage>> {
@ -47,3 +45,10 @@ export async function getPageBreadcrumbs(
const req = await api.post("/pages/breadcrumbs", { pageId });
return req.data;
}
export async function getRecentChanges(
spaceId?: string,
): Promise<IPagination<IPage>> {
const req = await api.post("/pages/recent", { spaceId });
return req.data;
}

View File

@ -1,31 +0,0 @@
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useAtom } from "jotai/index";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Box } from "@mantine/core";
import { IconNotes } from "@tabler/icons-react";
import React from "react";
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
import { TreeCollapse } from "@/features/page/tree/components/tree-collapse.tsx";
export default function SpaceContent() {
const [currentUser] = useAtom(currentUserAtom);
const { data: space } = useSpaceQuery(currentUser?.workspace.defaultSpaceId);
if (!space) {
return <div>Loading...</div>;
}
return (
<>
<Box p="sm" mx="auto">
<TreeCollapse
initiallyOpened={true}
icon={IconNotes}
label={space.name}
>
<SpaceTree spaceId={space.id} />
</TreeCollapse>
</Box>
</>
);
}

View File

@ -8,9 +8,9 @@ import {
useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts";
import React, { useEffect, useRef } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import classes from "@/features/page/tree/styles/tree.module.css";
import { ActionIcon, Menu, rem, Text } from "@mantine/core";
import { ActionIcon, Menu, rem } from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
@ -42,10 +42,11 @@ import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { useClipboard, useElementSize, useMergedRef } from "@mantine/hooks";
import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { buildPageSlug } from "@/features/page/page.utils.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { notifications } from "@mantine/notifications";
import { modals } from "@mantine/modals";
import APP_ROUTE from "@/lib/app-route.ts";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
interface SpaceTreeProps {
spaceId: string;
@ -54,7 +55,7 @@ interface SpaceTreeProps {
const openTreeNodesAtom = atom<OpenMap>({});
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
const { slugId } = useParams();
const { pageSlug } = useParams();
const { data, setData, controllers } =
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
const {
@ -72,20 +73,21 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef);
const isDataLoaded = useRef(false);
const { data: currentPage } = usePageQuery(slugId);
const location = useLocation();
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
useEffect(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
}, [hasNextPage, fetchNextPage, isFetching]);
}, [hasNextPage, fetchNextPage, isFetching, spaceId]);
useEffect(() => {
if (pagesData?.pages && !hasNextPage) {
const allItems = pagesData.pages.flatMap((page) => page.items);
const treeData = buildTree(allItems);
if (data.length < 1) {
if (data.length < 1 || data?.[0].spaceId !== spaceId) {
//Thoughts
// don't reset if there is data in state
// we only expect to call this once on initial load
@ -94,6 +96,7 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
// which looses async loaded children too
setData(treeData);
isDataLoaded.current = true;
setOpenTreeNodes({});
}
}
}, [pagesData, hasNextPage]);
@ -166,7 +169,10 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
useEffect(() => {
if (currentPage?.id) {
treeApiRef.current?.select(currentPage.id, { align: "auto" });
setTimeout(() => {
// focus on node and open all parents
treeApiRef.current?.select(currentPage.id, { align: "auto" });
}, 200);
} else {
treeApiRef.current?.deselectAll();
}
@ -212,6 +218,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const updatePageMutation = useUpdatePageMutation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
const { spaceSlug } = useParams();
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
if (!node.data.hasChildren) return;
@ -228,7 +235,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const newChildren = await queryClient.fetchQuery({
queryKey: ["sidebar-pages", params],
queryFn: () => getSidebarPages(params),
staleTime: 30 * 60 * 1000,
staleTime: 10 * 60 * 1000,
});
const childrenTree = buildTree(newChildren.items);
@ -246,7 +253,8 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
}
const handleClick = () => {
navigate(buildPageSlug(node.data.slugId, node.data.name));
const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
navigate(pageUrl);
};
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
@ -317,7 +325,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
node.data.icon ? (
node.data.icon
) : (
<IconFileDescription size="18px" />
<IconFileDescription size="18" />
)
}
removeEmojiAction={handleRemoveEmoji}
@ -381,29 +389,16 @@ interface NodeMenuProps {
function NodeMenu({ node, treeApi }: NodeMenuProps) {
const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal();
const handleCopyLink = () => {
const pageLink =
window.location.host + buildPageSlug(node.data.id, node.data.name);
clipboard.copy(pageLink);
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
clipboard.copy(pageUrl);
notifications.show({ message: "Link copied" });
};
const openDeleteModal = () =>
modals.openConfirmModal({
title: "Are you sure you want to delete this page?",
children: (
<Text size="sm">
Are you sure you want to delete this page? This action is
irreversible.
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: () => treeApi?.delete(node),
});
return (
<Menu shadow="md" width={200}>
<Menu.Target>
@ -440,7 +435,9 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
leftSection={
<IconTrash style={{ width: rem(14), height: rem(14) }} />
}
onClick={openDeleteModal}
onClick={() =>
openDeleteModal({ onConfirm: () => treeApi?.delete(node) })
}
>
Delete
</Menu.Item>

View File

@ -10,7 +10,7 @@ import {
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
import { useNavigate } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import {
useCreatePageMutation,
useDeletePageMutation,
@ -19,7 +19,8 @@ import {
} from "@/features/page/queries/page-query.ts";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { buildPageSlug } from "@/features/page/page.utils.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getSpaceUrl } from "@/lib/config.ts";
export function useTreeMutation<T>(spaceId: string) {
const [data, setData] = useAtom(treeDataAtom);
@ -29,6 +30,7 @@ export function useTreeMutation<T>(spaceId: string) {
const deletePageMutation = useDeletePageMutation();
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
const { spaceSlug } = useParams();
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
const payload: { spaceId: string; parentPageId?: string } = {
@ -65,7 +67,12 @@ export function useTreeMutation<T>(spaceId: string) {
tree.create({ parentId, index, data });
setData(tree.data);
navigate(buildPageSlug(createdPage.slugId, createdPage.title));
const pageUrl = buildPageUrl(
spaceSlug,
createdPage.slugId,
createdPage.title,
);
navigate(pageUrl);
return data;
};
@ -173,10 +180,12 @@ export function useTreeMutation<T>(spaceId: string) {
try {
await deletePageMutation.mutateAsync(args.ids[0]);
tree.drop({ id: args.ids[0] });
setData(tree.data);
if (tree.find(args.ids[0])) {
tree.drop({ id: args.ids[0] });
setData(tree.data);
}
navigate("/home");
navigate(getSpaceUrl(spaceSlug));
} catch (error) {
console.error("Failed to delete page:", error);
}

View File

@ -1,3 +1,5 @@
import { ISpace } from "@/features/space/types/space.types.ts";
export interface IPage {
id: string;
slugId: string;
@ -17,7 +19,7 @@ export interface IPage {
deletedAt: Date;
position: string;
hasChildren: boolean;
pageId: string;
space: Partial<ISpace>;
}
export interface IMovePage {
@ -33,3 +35,12 @@ export interface SidebarPagesParams {
pageId?: string;
page?: number; // pagination
}
export interface IPageInput {
pageId: string;
title: string;
parentPageId: string;
icon: string;
coverPhoto: string;
position: string;
}

View File

@ -5,17 +5,18 @@ import {
} from "@/features/search/services/search-service";
import {
IPageSearch,
IPageSearchParams,
ISuggestionResult,
SearchSuggestionParams,
} from "@/features/search/types/search.types";
export function usePageSearchQuery(
query: string,
params: IPageSearchParams,
): UseQueryResult<IPageSearch[], Error> {
return useQuery({
queryKey: ["page-search", query],
queryFn: () => searchPage(query),
enabled: !!query,
queryKey: ["page-search", params],
queryFn: () => searchPage(params),
enabled: !!params.query,
});
}

View File

@ -1,65 +1,92 @@
import { Group, Center, Text } from '@mantine/core';
import { Spotlight } from '@mantine/spotlight';
import { IconFileDescription, IconHome, IconSearch, IconSettings } from '@tabler/icons-react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDebouncedValue } from '@mantine/hooks';
import { usePageSearchQuery } from '@/features/search/queries/search-query';
import { Group, Center, Text } from "@mantine/core";
import { Spotlight } from "@mantine/spotlight";
import {
IconFileDescription,
IconHome,
IconSearch,
IconSettings,
} from "@tabler/icons-react";
import React, { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import { usePageSearchQuery } from "@/features/search/queries/search-query";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
export function SearchSpotlight() {
interface SearchSpotlightProps {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const { data: searchResults, isLoading, error } = usePageSearchQuery(debouncedSearchQuery)
const items = (searchResults && searchResults.length > 0 ? searchResults : [])
.map((item) => (
<Spotlight.Action key={item.title} onClick={() => navigate(`/p/${item.id}`)}>
<Group wrap="nowrap" w="100%">
<Center>
{item?.icon ? (
<span style={{ fontSize: "20px" }}>{ item.icon }</span>
) : (
<IconFileDescription size={20} />
)}
</Center>
const {
data: searchResults,
isLoading,
error,
} = usePageSearchQuery({ query: debouncedSearchQuery, spaceId });
<div style={{ flex: 1 }}>
<Text>{item.title}</Text>
const pages = (
searchResults && searchResults.length > 0 ? searchResults : []
).map((page) => (
<Spotlight.Action
key={page.id}
onClick={() =>
navigate(buildPageUrl(page.space.slug, page.slugId, page.title))
}
>
<Group wrap="nowrap" w="100%">
<Center>
{page?.icon ? (
<span style={{ fontSize: "20px" }}>{page.icon}</span>
) : (
<IconFileDescription size={20} />
)}
</Center>
{item?.highlight && (
<Text opacity={0.6} size="xs" dangerouslySetInnerHTML={{ __html: item.highlight }}/>
)}
</div>
<div style={{ flex: 1 }}>
<Text>{page.title}</Text>
</Group>
</Spotlight.Action>
));
{page?.highlight && (
<Text
opacity={0.6}
size="xs"
dangerouslySetInnerHTML={{ __html: page.highlight }}
/>
)}
</div>
</Group>
</Spotlight.Action>
));
return (
<>
<Spotlight.Root query={query}
onQueryChange={setQuery}
scrollable
overlayProps={{
backgroundOpacity: 0.55,
}}>
<Spotlight.Search placeholder="Search..."
leftSection={
<IconSearch size={20} stroke={1.5} />
} />
<Spotlight.Root
query={query}
onQueryChange={setQuery}
scrollable
overlayProps={{
backgroundOpacity: 0.55,
}}
>
<Spotlight.Search
placeholder="Search..."
leftSection={<IconSearch size={20} stroke={1.5} />}
/>
<Spotlight.ActionsList>
{query.length === 0 && items.length === 0 && <Spotlight.Empty>Start typing to search...</Spotlight.Empty>}
{query.length === 0 && pages.length === 0 && (
<Spotlight.Empty>Start typing to search...</Spotlight.Empty>
)}
{query.length > 0 && items.length === 0 && <Spotlight.Empty>No results found...</Spotlight.Empty>}
{query.length > 0 && pages.length === 0 && (
<Spotlight.Empty>No results found...</Spotlight.Empty>
)}
{items.length > 0 && items}
{pages.length > 0 && pages}
</Spotlight.ActionsList>
</Spotlight.Root>
</>
);
}

View File

@ -1,12 +1,15 @@
import api from "@/lib/api-client";
import {
IPageSearch,
IPageSearchParams,
ISuggestionResult,
SearchSuggestionParams,
} from "@/features/search/types/search.types";
export async function searchPage(query: string): Promise<IPageSearch[]> {
const req = await api.post<IPageSearch[]>("/search", { query });
export async function searchPage(
params: IPageSearchParams,
): Promise<IPageSearch[]> {
const req = await api.post<IPageSearch[]>("/search", params);
return req.data;
}

View File

@ -1,16 +1,19 @@
import { IUser } from "@/features/user/types/user.types.ts";
import { IGroup } from "@/features/group/types/group.types.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
export interface IPageSearch {
id: string;
title: string;
icon: string;
parentPageId: string;
slugId: string;
creatorId: string;
createdAt: Date;
updatedAt: Date;
rank: string;
highlight: string;
space: Partial<ISpace>;
}
export interface SearchSuggestionParams {
@ -23,3 +26,8 @@ export interface ISuggestionResult {
users?: Partial<IUser[]>;
groups?: Partial<IGroup[]>;
}
export interface IPageSearchParams {
query: string;
spaceId?: string;
}

View File

@ -33,7 +33,7 @@ export default function SpaceSettingsModal({
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{space?.name} space </Modal.Title>
<Modal.Title fw={500}>{space?.name}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>

View File

@ -0,0 +1,6 @@
.spaceName {
display: block;
width: 100%;
padding: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
}

View File

@ -0,0 +1,19 @@
import { UnstyledButton, Group, Avatar, Text, rem } from "@mantine/core";
import classes from "./space-name.module.css";
interface SpaceNameProps {
spaceName: string;
}
export function SpaceName({ spaceName }: SpaceNameProps) {
return (
<UnstyledButton className={classes.spaceName}>
<Group>
<div style={{ flex: 1 }}>
<Text size="md" fw={500}>
{spaceName}
</Text>
</div>
</Group>
</UnstyledButton>
);
}

View File

@ -1,5 +1,5 @@
.navbar {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
/*background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));*/
height: 100%;
width: 100%;
padding: var(--mantine-spacing-md);
@ -18,13 +18,6 @@
}
}
.searchCode {
font-weight: 700;
font-size: rem(10px);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
border: rem(1px) solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-7));
}
.menuItems {
padding-left: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
padding-right: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
@ -87,6 +80,6 @@
}
.activeButton {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
}

View File

@ -0,0 +1,142 @@
import {
UnstyledButton,
Text,
Group,
ActionIcon,
Tooltip,
rem,
} from "@mantine/core";
import { spotlight } from "@mantine/spotlight";
import {
IconSearch,
IconPlus,
IconSettings,
IconHome,
} from "@tabler/icons-react";
import classes from "./space-sidebar.module.css";
import React from "react";
import { useAtom } from "jotai";
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { Link, useLocation, useParams } from "react-router-dom";
import clsx from "clsx";
import { useDisclosure } from "@mantine/hooks";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { SpaceName } from "@/features/space/components/sidebar/space-name.tsx";
import { getSpaceUrl } from "@/lib/config.ts";
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
export function SpaceSidebar() {
const [tree] = useAtom(treeApiAtom);
const location = useLocation();
const [opened, { open: openSettings, close: closeSettings }] =
useDisclosure(false);
const { spaceSlug } = useParams();
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
function handleCreatePage() {
tree?.create({ parentId: null, type: "internal", index: 0 });
}
if (!space) {
return <></>;
}
return (
<>
<div className={classes.navbar}>
<div
className={classes.section}
style={{
border: "none",
paddingTop: "8px",
marginBottom: "0",
}}
>
<SpaceName spaceName={space?.name} />
</div>
<div className={classes.section}>
<div className={classes.menuItems}>
<UnstyledButton
component={Link}
to={getSpaceUrl(spaceSlug)}
className={clsx(
classes.menu,
location.pathname.toLowerCase() === getSpaceUrl(spaceSlug)
? classes.activeButton
: "",
)}
>
<div className={classes.menuItemInner}>
<IconHome
size={18}
className={classes.menuItemIcon}
stroke={2}
/>
<span>Overview</span>
</div>
</UnstyledButton>
<UnstyledButton className={classes.menu} onClick={spotlight.open}>
<div className={classes.menuItemInner}>
<IconSearch
size={18}
className={classes.menuItemIcon}
stroke={2}
/>
<span>Search</span>
</div>
</UnstyledButton>
<UnstyledButton className={classes.menu} onClick={openSettings}>
<div className={classes.menuItemInner}>
<IconSettings
size={18}
className={classes.menuItemIcon}
stroke={2}
/>
<span>Space settings</span>
</div>
</UnstyledButton>
</div>
</div>
<div className={classes.section}>
<Group className={classes.pagesHeader} justify="space-between">
<Text size="xs" fw={500} c="dimmed">
Pages
</Text>
<Tooltip label="Create page" withArrow position="right">
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
>
<IconPlus
style={{ width: rem(12), height: rem(12) }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
</Group>
<div className={classes.pages}>
<SpaceTree spaceId={space.id} />
</div>
</div>
</div>
<SpaceSettingsModal
opened={opened}
onClose={closeSettings}
spaceId={space?.slug}
/>
<SearchSpotlight spaceId={space.id} />
</>
);
}

View File

@ -0,0 +1,25 @@
.card {
background-color: var(--mantine-color-body);
@mixin hover {
box-shadow: var(--mantine-shadow-xs);
transform: scale(1.02);
}
}
.cardSection {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
}
.title {
font-family:
Greycliff CF,
var(--mantine-font-family);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.icon {
margin-right: rem(5px);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2));
}

View File

@ -0,0 +1,46 @@
import { Text, Avatar, SimpleGrid, Card, rem } from "@mantine/core";
import React from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { getSpaceUrl } from "@/lib/config.ts";
import { Link } from "react-router-dom";
import classes from "./space-grid.module.css";
import { formatMemberCount } from "@/lib";
export default function SpaceGrid() {
const { data, isLoading } = useGetSpacesQuery();
const cards = data?.items.map((space, index) => (
<Card
key={space.id}
p="xs"
radius="md"
component={Link}
to={getSpaceUrl(space.slug)}
className={classes.card}
withBorder
>
<Card.Section className={classes.cardSection} h={40}></Card.Section>
<Avatar variant="filled" size="md" mt={rem(-20)}>
{space.name.charAt(0).toUpperCase()}
</Avatar>
<Text fz="md" fw={500} mt="xs" className={classes.title}>
{space.name}
</Text>
<Text c="dimmed" size="xs" fw={700} mt="md">
{formatMemberCount(space.memberCount)}
</Text>
</Card>
));
return (
<>
<Text fz="sm" fw={500} mb={"md"}>
Spaces you belong to
</Text>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
</>
);
}

View File

@ -0,0 +1,23 @@
import { Text, Tabs, Space } from "@mantine/core";
import { IconClockHour3 } from "@tabler/icons-react";
import SpaceRecentChanges from "@/features/space/components/space-recent-changes.tsx";
export default function SpaceHomeTabs() {
return (
<Tabs defaultValue="recent">
<Tabs.List>
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
<Text size="sm" fw={500}>
Recent changes
</Text>
</Tabs.Tab>
</Tabs.List>
<Space my="md" />
<Tabs.Panel value="recent">
<SpaceRecentChanges />
</Tabs.Panel>
</Tabs>
);
}

View File

@ -1,10 +1,10 @@
.user {
.page {
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
}
}
}

View File

@ -0,0 +1,53 @@
import { Text, Group, Stack, UnstyledButton, Divider } from "@mantine/core";
import classes from "./space-home.module.css";
import { Link, useParams } from "react-router-dom";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
function SpaceRecentChanges() {
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const { data, isLoading, isError } = useRecentChangesQuery(space?.id);
if (isLoading) {
return <></>;
}
if (isError) {
return <Text>Failed to fetch recent pages</Text>;
}
return (
data && (
<div>
{data.items.map((page) => (
<div key={page.id}>
<UnstyledButton
component={Link}
to={buildPageUrl(space.slug, page.slugId, page.title)}
className={classes.page}
p="xs"
>
<Group wrap="nowrap">
<Stack gap="xs" style={{ flex: 1 }}>
<Text fw={500} size="sm" lineClamp={1}>
{page.title || "Untitled"}
</Text>
</Stack>
<Text c="dimmed" size="xs" fw={500}>
{formattedDate(page.updatedAt)}
</Text>
</Group>
</UnstyledButton>
<Divider />
</div>
))}
</div>
)
);
}
export default SpaceRecentChanges;

View File

@ -41,6 +41,16 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
});
}
export function useGetSpaceBySlugQuery(
spaceId: string,
): UseQueryResult<ISpace, Error> {
return useQuery({
queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
});
}
export function useUpdateSpaceMutation() {
const queryClient = useQueryClient();

View File

@ -3,6 +3,7 @@ export interface ISpace {
name: string;
description: string;
icon: string;
slug: string;
hostname: string;
creatorId: string;
createdAt: Date;

View File

@ -1,5 +1,5 @@
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
import { useAtom } from 'jotai';
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { useAtom } from "jotai";
const useToggleAside = () => {
const [asideState, setAsideState] = useAtom(asideStateAtom);

View File

@ -4,6 +4,18 @@ const APP_ROUTE = {
LOGIN: "/login",
SIGNUP: "/signup",
},
SETTINGS: {
ACCOUNT: {
PROFILE: "/settings/account/profile",
PREFERENCES: "/settings/account/preferences",
},
WORKSPACE: {
GENERAL: "/settings/workspace",
MEMBERS: "/settings/members",
GROUPS: "/settings/groups",
SPACES: "/settings/spaces",
},
},
};
export default APP_ROUTE;

View File

@ -28,9 +28,17 @@ export function getCollaborationUrl(): string {
}
export function getAvatarUrl(avatarUrl: string) {
if (!avatarUrl) {
return null;
}
if (avatarUrl.startsWith("http")) {
return avatarUrl;
}
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
}
export function getSpaceUrl(spaceSlug: string) {
return "/s/" + spaceSlug;
}

View File

@ -0,0 +1 @@
export * from "./utils";

View File

@ -0,0 +1,15 @@
export function formatMemberCount(memberCount: number): string {
if (memberCount === 1) {
return "1 member";
} else {
return `${memberCount} members`;
}
}
export function extractPageSlugId(input: string): string {
if (!input) {
return undefined;
}
const parts = input.split("-");
return parts.length > 1 ? parts[parts.length - 1] : input;
}

View File

@ -1,13 +1,15 @@
import { Container } from '@mantine/core';
import HomeTabs from '@/features/home/components/home-tabs';
import { Container, Space } from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs";
import SpaceGrid from "@/features/space/components/space-grid.tsx";
export default function Home() {
return (
<Container size={'800'} pt="xl">
<Container size={"800"} pt="xl">
<SpaceGrid />
<HomeTabs/>
<Space h="xl" />
<HomeTabs />
</Container>
);
}

View File

@ -0,0 +1,28 @@
import { useNavigate, useParams } from "react-router-dom";
import { useEffect } from "react";
import { usePageQuery } from "@/features/page/queries/page-query";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
export default function PageRedirect() {
const { pageSlug } = useParams();
const {
data: page,
isLoading: pageIsLoading,
isError,
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const navigate = useNavigate();
useEffect(() => {
if (page) {
const pageUrl = buildPageUrl(page.space.slug, page.slugId, page.title);
navigate(pageUrl);
}
}, [page]);
if (pageIsLoading) {
return <></>;
}
return null;
}

View File

@ -3,10 +3,16 @@ import { usePageQuery } from "@/features/page/queries/page-query";
import { FullEditor } from "@/features/editor/full-editor";
import HistoryModal from "@/features/page-history/components/history-modal";
import { Helmet } from "react-helmet-async";
import PageHeader from "@/features/page/components/header/page-header.tsx";
import { extractPageSlugId } from "@/lib";
export default function Page() {
const { slugId } = useParams();
const { data: page, isLoading, isError } = usePageQuery(slugId);
const { pageSlug, spaceSlug } = useParams();
const {
data: page,
isLoading,
isError,
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
if (isLoading) {
return <></>;
@ -23,7 +29,15 @@ export default function Page() {
<Helmet>
<title>{page.title}</title>
</Helmet>
<FullEditor pageId={page.id} title={page.title} slugId={page.slugId} />
<PageHeader />
<FullEditor
pageId={page.id}
title={page.title}
slugId={page.slugId}
spaceSlug={page?.space?.slug || spaceSlug}
/>
<HistoryModal pageId={page.id} />
</div>
)

View File

@ -1,4 +1,4 @@
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx";
export default function AccountPreferences() {

View File

@ -3,7 +3,7 @@ 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";
import SettingsTitle from "@/components/settings/settings-title.tsx";
export default function AccountSettings() {
return (

View File

@ -1,4 +1,4 @@
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import GroupMembersList from "@/features/group/components/group-members";
import GroupDetails from "@/features/group/components/group-details";

View File

@ -1,5 +1,5 @@
import GroupList from "@/features/group/components/group-list";
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Group, Text } from "@mantine/core";
import CreateGroupModal from "@/features/group/components/create-group-modal";

View File

@ -1,4 +1,4 @@
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import SpaceList from "@/features/space/components/space-list.tsx";
export default function Spaces() {

View File

@ -2,7 +2,7 @@ import WorkspaceInviteSection from "@/features/workspace/components/members/comp
import WorkspaceInviteModal from "@/features/workspace/components/members/components/workspace-invite-modal";
import { Divider, Group, SegmentedControl, 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";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";

View File

@ -1,4 +1,4 @@
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
export default function WorkspaceSettings() {

View File

@ -0,0 +1,15 @@
import { Container } from "@mantine/core";
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
import { useParams } from "react-router-dom";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
export default function SpaceHome() {
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
return (
<Container size={"800"} pt="xl">
{space && <SpaceHomeTabs />}
</Container>
);
}

View File

@ -1,16 +1,16 @@
import { createTheme, MantineColorsTuple } from '@mantine/core';
import { createTheme, MantineColorsTuple } from "@mantine/core";
const blue: MantineColorsTuple = [
'#e8f3ff',
'#d0e3ff',
'#9ec4fc',
'#69a3fb',
'#4087fa',
'#2975fa',
'#0052cc', //1c6cfb
'#0f5be1',
'#0051c9',
'#0046b1',
"#e7f3ff",
"#d0e4ff",
"#a1c6fa",
"#6ea6f6",
"#458bf2",
"#2b7af1",
"#0b60d8", //
"#1b72f2",
"#0056c1",
"#004aac",
];
export const theme = createTheme({
@ -18,4 +18,3 @@ export const theme = createTheme({
blue,
},
});

View File

@ -1,8 +1,4 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import {
AbilityBuilder,
createMongoAbility,

View File

@ -1,7 +1,14 @@
import { IsString, IsUUID } from 'class-validator';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class PageIdDto {
@IsString()
@IsNotEmpty()
pageId: string;
}
@ -14,3 +21,9 @@ export class PageHistoryIdDto {
@IsUUID()
historyId: string;
}
export class PageInfoDto extends PageIdDto {
@IsOptional()
@IsBoolean()
includeSpace: boolean;
}

View File

@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class RecentPageDto {
@IsOptional()
@IsString()
spaceId: string;
}

View File

@ -12,7 +12,7 @@ import { PageService } from './services/page.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto } from './dto/move-page.dto';
import { PageHistoryIdDto, PageIdDto } from './dto/page.dto';
import { PageHistoryIdDto, PageIdDto, PageInfoDto } from './dto/page.dto';
import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../decorators/auth-user.decorator';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
@ -26,6 +26,7 @@ import {
} from '../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@ -39,8 +40,10 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('/info')
async getPage(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(pageIdDto.pageId);
async getPage(@Body() dto: PageInfoDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId, {
includeSpace: true,
});
if (!page) {
throw new NotFoundException('Page not found');
@ -117,24 +120,28 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('recent')
async getRecentSpacePages(
async getRecentPages(
@Body() recentPageDto: RecentPageDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
workspace.defaultSpaceId,
);
if (recentPageDto.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
recentPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.getRecentSpacePages(
recentPageDto.spaceId,
pagination,
);
}
return this.pageService.getRecentSpacePages(
workspace.defaultSpaceId,
pagination,
);
return this.pageService.getRecentPages(user.id, pagination);
}
// TODO: scope to workspaces

View File

@ -18,7 +18,7 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { genPageShortId } from '../../../helpers/nanoid.utils';
import { generateSlugId } from '../../../helpers';
@Injectable()
export class PageService {
@ -31,8 +31,13 @@ export class PageService {
pageId: string,
includeContent?: boolean,
includeYdoc?: boolean,
includeSpace?: boolean,
): Promise<Page> {
return this.pageRepo.findById(pageId, { includeContent, includeYdoc });
return this.pageRepo.findById(pageId, {
includeContent,
includeYdoc,
includeSpace,
});
}
async create(
@ -92,7 +97,7 @@ export class PageService {
}
const createdPage = await this.pageRepo.insertPage({
slugId: genPageShortId(),
slugId: generateSlugId(),
title: createPageDto.title,
position: pagePosition,
icon: createPageDto.icon,
@ -266,9 +271,14 @@ export class PageService {
spaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
const pages = await this.pageRepo.getRecentPageUpdates(spaceId, pagination);
return await this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
}
return pages;
async getRecentPages(
userId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
return await this.pageRepo.getRecentPages(userId, pagination);
}
async forceDelete(pageId: string): Promise<void> {

View File

@ -1,9 +1,19 @@
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
export class SearchDTO {
@IsString()
query: string;
@IsNotEmpty()
@IsString()
spaceId: string;
@IsOptional()
@IsString()
creatorId?: string;

View File

@ -1,34 +1,51 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotImplementedException,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { SearchService } from './search.service';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
import { Workspace } from '@docmost/db/types/entity.types';
import { User, Workspace } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { AuthUser } from '../../decorators/auth-user.decorator';
@UseGuards(JwtAuthGuard)
@Controller('search')
export class SearchController {
constructor(private readonly searchService: SearchService) {}
constructor(
private readonly searchService: SearchService,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post()
async pageSearch(
@Body() searchDto: SearchDTO,
@AuthWorkspace() workspace: Workspace,
) {
return this.searchService.searchPage(
searchDto.query,
searchDto,
workspace.id,
);
async pageSearch(@Body() searchDto: SearchDTO, @AuthUser() user: User) {
if (searchDto.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
searchDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.searchService.searchPage(searchDto.query, searchDto);
}
// TODO: search all spaces user is a member of if no spaceId provided
throw new NotImplementedException();
}
@Post('suggest')

View File

@ -4,17 +4,20 @@ import { SearchResponseDto } from './dto/search-response.dto';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const tsquery = require('pg-tsquery')();
@Injectable()
export class SearchService {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
constructor(
@InjectKysely() private readonly db: KyselyDB,
private pageRepo: PageRepo,
) {}
async searchPage(
query: string,
searchParams: SearchDTO,
workspaceId: string,
): Promise<SearchResponseDto[]> {
if (query.length < 1) {
return;
@ -28,6 +31,7 @@ export class SearchService {
'title',
'icon',
'parentPageId',
'slugId',
'creatorId',
'createdAt',
'updatedAt',
@ -36,7 +40,8 @@ export class SearchService {
'highlight',
),
])
.where('workspaceId', '=', workspaceId)
.select((eb) => this.pageRepo.withSpace(eb))
.where('spaceId', '=', searchParams.spaceId)
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
.$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId),

View File

@ -3,6 +3,6 @@ import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
export class SpaceIdDto {
@IsString()
@IsNotEmpty()
@IsUUID()
//@IsUUID()
spaceId: string;
}

View File

@ -8,11 +8,12 @@ import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
import { InjectKysely } from 'nestjs-kysely';
import { SpaceMember, User } from '@docmost/db/types/entity.types';
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
import { SpaceRole } from '../../../helpers/types/permission';
import { PaginationResult } from '@docmost/db/pagination/pagination';
@Injectable()
export class SpaceMemberService {
@ -49,11 +50,6 @@ export class SpaceMemberService {
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
//const existingSpaceUser = await manager.findOneBy(SpaceMember, {
// userId: userId,
// spaceId: spaceId,
// });
// validations?
await this.spaceMemberRepo.insertSpaceMember(
{
groupId: groupId,
@ -276,4 +272,11 @@ export class SpaceMemberService {
);
}
}
async getUserSpaces(
userId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Space>> {
return await this.spaceMemberRepo.getUserSpaces(userId, pagination);
}
}

View File

@ -5,6 +5,7 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
@ -41,10 +42,8 @@ export class SpaceController {
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
// TODO: only show spaces user can see. e.g open and private with user being a member
return this.spaceService.getWorkspaceSpaces(workspace.id, pagination);
return this.spaceMemberService.getUserSpaces(user.id, pagination);
}
@HttpCode(HttpStatus.OK)
@ -54,15 +53,21 @@ export class SpaceController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
const space = await this.spaceService.getSpaceInfo(
spaceIdDto.spaceId,
workspace.id,
);
if (!space) {
throw new NotFoundException('Space not found');
}
const ability = await this.spaceAbility.createForUser(user, space.id);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
return this.spaceService.getSpaceInfo(spaceIdDto.spaceId, workspace.id);
return space;
}
@HttpCode(HttpStatus.OK)

View File

@ -9,7 +9,7 @@ export async function up(db: Kysely<any>): Promise<void> {
)
.addColumn('name', 'varchar', (col) => col)
.addColumn('description', 'text', (col) => col)
.addColumn('slug', 'varchar', (col) => col)
.addColumn('slug', 'varchar', (col) => col.notNull())
.addColumn('logo', 'varchar', (col) => col)
.addColumn('visibility', 'varchar', (col) =>
col.defaultTo(SpaceVisibility.OPEN).notNull(),

View File

@ -10,10 +10,17 @@ import {
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { validate as isValidUUID } from 'uuid';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class PageRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
constructor(
@InjectKysely() private readonly db: KyselyDB,
private spaceMemberRepo: SpaceMemberRepo,
) {}
private baseFields: Array<keyof Page> = [
'id',
@ -38,6 +45,7 @@ export class PageRepo {
opts?: {
includeContent?: boolean;
includeYdoc?: boolean;
includeSpace?: boolean;
},
): Promise<Page> {
let query = this.db
@ -46,6 +54,10 @@ export class PageRepo {
.$if(opts?.includeContent, (qb) => qb.select('content'))
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc'));
if (opts?.includeSpace) {
query = query.select((eb) => this.withSpace(eb));
}
if (isValidUUID(pageId)) {
query = query.where('id', '=', pageId);
} else {
@ -96,12 +108,11 @@ export class PageRepo {
await query.execute();
}
async getRecentPageUpdates(spaceId: string, pagination: PaginationOptions) {
//TODO: should fetch pages from all spaces the user is member of
// for now, fetch from default space
async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pages')
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', '=', spaceId)
.orderBy('updatedAt', 'desc');
@ -112,4 +123,31 @@ export class PageRepo {
return result;
}
async getRecentPages(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('pages')
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', 'in', userSpaceIds)
.orderBy('updatedAt', 'desc');
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
return jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.name', 'spaces.slug'])
.whereRef('spaces.id', '=', 'pages.spaceId'),
).as('space');
}
}

View File

@ -11,12 +11,14 @@ import { PaginationOptions } from '../../pagination/pagination-options';
import { MemberInfo, UserSpaceRole } from './types';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
@Injectable()
export class SpaceMemberRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
private readonly spaceRepo: SpaceRepo,
) {}
async insertSpaceMember(
@ -184,4 +186,52 @@ export class SpaceMemberRepo {
}
return roles;
}
async getUserSpaceIds(userId: string): Promise<string[]> {
const membership = await this.db
.selectFrom('spaceMembers')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select(['spaces.id'])
.where('userId', '=', userId)
.union(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select(['spaces.id'])
.where('groupUsers.userId', '=', userId),
)
.execute();
return membership.map((space) => space.id);
}
async getUserSpaces(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.getUserSpaceIds(userId);
let query = this.db
.selectFrom('spaces')
.selectAll('spaces')
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
//.where('workspaceId', '=', workspaceId)
.where('id', 'in', userSpaceIds)
.orderBy('createdAt', 'asc');
if (pagination.query) {
query = query.where((eb) =>
eb('name', 'ilike', `%${pagination.query}%`).or(
'description',
'ilike',
`%${pagination.query}%`,
),
);
}
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
}

View File

@ -11,6 +11,7 @@ import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { DB } from '@docmost/db/types/db';
import { validate as isValidUUID } from 'uuid';
@Injectable()
export class SpaceRepo {
@ -22,13 +23,19 @@ export class SpaceRepo {
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Space> {
const db = dbOrTx(this.db, opts?.trx);
return db
let query = db
.selectFrom('spaces')
.selectAll('spaces')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
.where('workspaceId', '=', workspaceId);
if (isValidUUID(spaceId)) {
query = query.where('id', '=', spaceId);
} else {
query = query.where('slug', '=', spaceId);
}
return query.executeTakeFirst();
}
async findBySlug(

View File

@ -6,11 +6,15 @@ import { hashPassword } from '../../../helpers';
import { dbOrTx } from '@docmost/db/utils';
import {
InsertableUser,
Space,
UpdatableUser,
User,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import {
executeWithPagination,
PaginationResult,
} from '@docmost/db/pagination/pagination';
@Injectable()
export class UserRepo {
@ -152,4 +156,31 @@ export class UserRepo {
return result;
}
/*
async getSpaceIds(
workspaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Space>> {
const spaces = await this.spaceRepo.getSpacesInWorkspace(
workspaceId,
pagination,
);
return spaces;
}
async getUserSpaces(
workspaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Space>> {
const spaces = await this.spaceRepo.getSpacesInWorkspace(
workspaceId,
pagination,
);
return spaces;
}
*/
}

View File

@ -6,4 +6,4 @@ export const nanoIdGen = customAlphabet(alphabet, 10);
const slugIdAlphabet =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
export const genPageShortId = customAlphabet(slugIdAlphabet, 12);
export const generateSlugId = customAlphabet(slugIdAlphabet, 12);