mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-17 18:21:04 +10:00
refactor layout
* ui polishing * frontend and backend fixes
This commit is contained in:
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
import { Skeleton } from '@mantine/core';
|
||||
|
||||
export default function PageListSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
import { Text, Group, Stack, UnstyledButton, Divider } 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";
|
||||
|
||||
function RecentChanges() {
|
||||
const { data, isLoading, isError } = useRecentChangesQuery();
|
||||
@ -18,31 +18,33 @@ 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 }}>
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || "Untitled"}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Text c="dimmed" size="xs" fw={500}>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Text c="dimmed" size="xs" fw={500}>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,118 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
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 {
|
||||
Button,
|
||||
Anchor,
|
||||
Popover,
|
||||
Breadcrumbs,
|
||||
ActionIcon,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./breadcrumb.module.css";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { 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) {
|
||||
return `${icon} ${name}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export default function Breadcrumb() {
|
||||
const treeData = useAtomValue(treeDataAtom);
|
||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
||||
SpaceTreeNode[] | null
|
||||
>(null);
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: currentPage } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData?.length > 0 && currentPage) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
|
||||
setBreadcrumbNodes(breadcrumb || null);
|
||||
}
|
||||
}, [currentPage?.id, treeData]);
|
||||
|
||||
const HiddenNodesTooltipContent = () =>
|
||||
breadcrumbNodes?.slice(1, -2).map((node) => (
|
||||
<Button.Group orientation="vertical" key={node.id}>
|
||||
<Button
|
||||
justify="start"
|
||||
component={Link}
|
||||
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<Text fz={"sm"} className={classes.truncatedText}>
|
||||
{getTitle(node.name, node.icon)}
|
||||
</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
));
|
||||
|
||||
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) return [];
|
||||
|
||||
if (breadcrumbNodes.length > 3) {
|
||||
const firstNode = breadcrumbNodes[0];
|
||||
const secondLastNode = breadcrumbNodes[breadcrumbNodes.length - 2];
|
||||
const lastNode = breadcrumbNodes[breadcrumbNodes.length - 1];
|
||||
|
||||
return [
|
||||
renderAnchor(firstNode),
|
||||
<Popover
|
||||
width={250}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="xl"
|
||||
key="hidden-nodes"
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon color="gray" variant="transparent">
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<HiddenNodesTooltipContent />
|
||||
</Popover.Dropdown>
|
||||
</Popover>,
|
||||
renderAnchor(secondLastNode),
|
||||
renderAnchor(lastNode),
|
||||
];
|
||||
}
|
||||
|
||||
return breadcrumbNodes.map(renderAnchor);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ overflow: "hidden" }}>
|
||||
{breadcrumbNodes && (
|
||||
<Breadcrumbs className={classes.breadcrumbs}>
|
||||
{getBreadcrumbItems()}
|
||||
</Breadcrumbs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconDots,
|
||||
IconHistory,
|
||||
IconLink,
|
||||
IconMessage,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { 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 PageHeaderMenu() {
|
||||
const toggleAside = useToggleAside();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label="Comments" openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
onClick={() => toggleAside("comments")}
|
||||
>
|
||||
<IconMessage size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<PageActionMenu />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PageActionMenu() {
|
||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: page, isLoading } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
getAppUrl() + buildPageUrl(spaceSlug, page.slugId, page.title);
|
||||
|
||||
clipboard.copy(pageUrl);
|
||||
notifications.show({ message: "Link copied" });
|
||||
};
|
||||
|
||||
const openHistoryModal = () => {
|
||||
setHistoryModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeletePage = () => {
|
||||
openDeleteModal({ onConfirm: () => tree?.delete(page.id) });
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} stroke={2} />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} stroke={2} />}
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
Page history
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color={"red"}
|
||||
leftSection={<IconTrash size={16} stroke={2} />}
|
||||
onClick={handleDeletePage}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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)}`;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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) => {
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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));
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
.navbar {
|
||||
/*background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));*/
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-left: calc(var(--mantine-spacing-md) * -1);
|
||||
margin-right: calc(var(--mantine-spacing-md) * -1);
|
||||
margin-bottom: var(--mantine-spacing-md);
|
||||
|
||||
&:not(:last-of-type) {
|
||||
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.menuItems {
|
||||
padding-left: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
|
||||
padding-right: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
|
||||
padding-bottom: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
padding: rem(4px) var(--mantine-spacing-xs);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
|
||||
&:hover {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
.menuItemInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
margin-right: var(--mantine-spacing-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.pages {
|
||||
padding-left: calc(var(--mantine-spacing-md) - rem(6px));
|
||||
padding-right: calc(var(--mantine-spacing-md) - rem(6px));
|
||||
padding-bottom: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.pagesHeader {
|
||||
padding-left: calc(var(--mantine-spacing-md) + rem(2px));
|
||||
padding-right: var(--mantine-spacing-md);
|
||||
margin-bottom: rem(5px);
|
||||
}
|
||||
|
||||
.pageLink {
|
||||
display: block;
|
||||
padding: rem(8px) var(--mantine-spacing-xs);
|
||||
text-decoration: none;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
}
|
||||
}
|
||||
|
||||
.activeButton {
|
||||
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));
|
||||
}
|
||||
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
46
apps/client/src/features/space/components/space-grid.tsx
Normal file
46
apps/client/src/features/space/components/space-grid.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
.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-1), var(--mantine-color-dark-8));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ export interface ISpace {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
slug: string;
|
||||
hostname: string;
|
||||
creatorId: string;
|
||||
createdAt: Date;
|
||||
|
||||
Reference in New Issue
Block a user