mirror of
https://github.com/docmost/docmost.git
synced 2025-11-17 04:01:10 +10:00
updates and fixes
* seo friendly urls * custom client serve-static module * database fixes * fix recent pages * other fixes
This commit is contained in:
15
apps/client/src/features/page/page.utils.ts
Normal file
15
apps/client/src/features/page/page.utils.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
|
||||
export const buildPageSlug = (
|
||||
pageShortId: string,
|
||||
pageTitle?: string,
|
||||
): string => {
|
||||
const titleSlug = slugify(pageTitle?.substring(0, 99) || "untitled", {
|
||||
customReplacements: [
|
||||
["♥", ""],
|
||||
["🦄", ""],
|
||||
],
|
||||
});
|
||||
|
||||
return `/p/${pageShortId}/${titleSlug}`;
|
||||
};
|
||||
@ -27,16 +27,21 @@ import { buildTree } from "@/features/page/tree/utils";
|
||||
|
||||
const RECENT_CHANGES_KEY = ["recentChanges"];
|
||||
|
||||
export function usePageQuery(pageId: string): UseQueryResult<IPage, Error> {
|
||||
export function usePageQuery(
|
||||
pageIdOrSlugId: string,
|
||||
): UseQueryResult<IPage, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["pages", pageId],
|
||||
queryFn: () => getPageById(pageId),
|
||||
enabled: !!pageId,
|
||||
queryKey: ["pages", pageIdOrSlugId],
|
||||
queryFn: () => getPageById(pageIdOrSlugId),
|
||||
enabled: !!pageIdOrSlugId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecentChangesQuery(): UseQueryResult<IPage[], Error> {
|
||||
export function useRecentChangesQuery(): UseQueryResult<
|
||||
IPagination<IPage>,
|
||||
Error
|
||||
> {
|
||||
return useQuery({
|
||||
queryKey: RECENT_CHANGES_KEY,
|
||||
queryFn: () => getRecentChanges(),
|
||||
@ -60,7 +65,7 @@ export function useUpdatePageMutation() {
|
||||
mutationFn: (data) => updatePage(data),
|
||||
onSuccess: (data) => {
|
||||
// update page in cache
|
||||
queryClient.setQueryData(["pages", data.id], data);
|
||||
queryClient.setQueryData(["pages", data.slugId], data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -29,8 +29,8 @@ export async function movePage(data: IMovePage): Promise<void> {
|
||||
await api.post<void>("/pages/move", data);
|
||||
}
|
||||
|
||||
export async function getRecentChanges(): Promise<IPage[]> {
|
||||
const req = await api.post<IPage[]>("/pages/recent");
|
||||
export async function getRecentChanges(): Promise<IPagination<IPage>> {
|
||||
const req = await api.post("/pages/recent");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
|
||||
@ -4,12 +4,13 @@ import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import {
|
||||
fetchAncestorChildren,
|
||||
useGetRootSidebarPagesQuery,
|
||||
usePageQuery,
|
||||
useUpdatePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||
import { ActionIcon, Menu, rem, Text } from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
@ -18,7 +19,6 @@ import {
|
||||
IconLink,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
IconStar,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
@ -39,9 +39,12 @@ import {
|
||||
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||
import { useElementSize, useMergedRef } from "@mantine/hooks";
|
||||
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 { notifications } from "@mantine/notifications";
|
||||
import { modals } from "@mantine/modals";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
@ -50,7 +53,7 @@ interface SpaceTreeProps {
|
||||
const openTreeNodesAtom = atom<OpenMap>({});
|
||||
|
||||
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
const { pageId } = useParams();
|
||||
const { slugId } = useParams();
|
||||
const { data, setData, controllers } =
|
||||
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
|
||||
const {
|
||||
@ -68,6 +71,7 @@ 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);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
@ -94,24 +98,24 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (isDataLoaded.current) {
|
||||
if (isDataLoaded.current && currentPage) {
|
||||
// check if pageId node is present in the tree
|
||||
const node = dfs(treeApiRef.current.root, pageId);
|
||||
const node = dfs(treeApiRef.current.root, currentPage.id);
|
||||
if (node) {
|
||||
// if node is found, no need to traverse its ancestors
|
||||
return;
|
||||
}
|
||||
|
||||
// if not found, fetch and build its ancestors and their children
|
||||
if (!pageId) return;
|
||||
const ancestors = await getPageBreadcrumbs(pageId);
|
||||
if (!currentPage.id) return;
|
||||
const ancestors = await getPageBreadcrumbs(currentPage.id);
|
||||
|
||||
if (ancestors && ancestors?.length > 1) {
|
||||
let flatTreeItems = [...buildTree(ancestors)];
|
||||
|
||||
const fetchAndUpdateChildren = async (ancestor: IPage) => {
|
||||
// we don't want to fetch the children of the opened page
|
||||
if (ancestor.id === pageId) {
|
||||
if (ancestor.id === currentPage.id) {
|
||||
return;
|
||||
}
|
||||
const children = await fetchAncestorChildren({
|
||||
@ -148,7 +152,7 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
|
||||
setTimeout(() => {
|
||||
// focus on node and open all parents
|
||||
treeApiRef.current.select(pageId);
|
||||
treeApiRef.current.select(currentPage.id);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
@ -156,13 +160,15 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [isDataLoaded.current, pageId]);
|
||||
}, [isDataLoaded.current, currentPage?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
treeApiRef.current?.select(pageId, { align: "auto" });
|
||||
}, 200);
|
||||
}, [pageId]);
|
||||
if (currentPage) {
|
||||
setTimeout(() => {
|
||||
treeApiRef.current?.select(currentPage.id, { align: "auto" });
|
||||
}, 200);
|
||||
}
|
||||
}, [currentPage?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (treeApiRef.current) {
|
||||
@ -241,7 +247,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/p/${node.id}`);
|
||||
navigate(buildPageSlug(node.data.slugId, node.data.name));
|
||||
};
|
||||
|
||||
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
|
||||
@ -333,6 +339,7 @@ interface CreateNodeProps {
|
||||
treeApi: TreeApi<SpaceTreeNode>;
|
||||
onExpandTree?: () => void;
|
||||
}
|
||||
|
||||
function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
||||
function handleCreate() {
|
||||
if (node.data.hasChildren && node.children.length === 0) {
|
||||
@ -366,7 +373,32 @@ interface NodeMenuProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
treeApi: TreeApi<SpaceTreeNode>;
|
||||
}
|
||||
|
||||
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageLink =
|
||||
window.location.host + buildPageSlug(node.data.id, node.data.name);
|
||||
clipboard.copy(pageLink);
|
||||
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>
|
||||
@ -386,13 +418,12 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyLink();
|
||||
}}
|
||||
>
|
||||
Copy link
|
||||
@ -404,7 +435,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={() => treeApi?.delete(node)}
|
||||
onClick={openDeleteModal}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
@ -417,6 +448,7 @@ interface PageArrowProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
onExpandTree?: () => void;
|
||||
}
|
||||
|
||||
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
||||
return (
|
||||
<ActionIcon
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
import { IconChevronRight } from "@tabler/icons-react";
|
||||
import classes from "./tree-collapse.module.css";
|
||||
|
||||
interface LinksGroupProps {
|
||||
interface TreeCollapseProps {
|
||||
icon?: React.FC<any>;
|
||||
label: string;
|
||||
initiallyOpened?: boolean;
|
||||
@ -22,7 +22,7 @@ export function TreeCollapse({
|
||||
label,
|
||||
initiallyOpened,
|
||||
children,
|
||||
}: LinksGroupProps) {
|
||||
}: TreeCollapseProps) {
|
||||
const [opened, setOpened] = useState(initiallyOpened || false);
|
||||
|
||||
return (
|
||||
|
||||
@ -19,6 +19,7 @@ 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";
|
||||
|
||||
export function useTreeMutation<T>(spaceId: string) {
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
@ -46,6 +47,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
|
||||
const data = {
|
||||
id: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
name: "",
|
||||
position: createdPage.position,
|
||||
children: [],
|
||||
@ -63,7 +65,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
tree.create({ parentId, index, data });
|
||||
setData(tree.data);
|
||||
|
||||
navigate(`/p/${createdPage.id}`);
|
||||
navigate(buildPageSlug(createdPage.slugId, createdPage.title));
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
export type SpaceTreeNode = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
position: string;
|
||||
slug?: string;
|
||||
spaceId: string;
|
||||
parentPageId: string;
|
||||
hasChildren: boolean;
|
||||
|
||||
@ -17,6 +17,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
pages.forEach((page) => {
|
||||
pageMap[page.id] = {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
name: page.title,
|
||||
icon: page.icon,
|
||||
position: page.position,
|
||||
|
||||
@ -1,28 +1,23 @@
|
||||
export interface IPage {
|
||||
pageId: string;
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
html: string;
|
||||
slug: string;
|
||||
icon: string;
|
||||
coverPhoto: string;
|
||||
editor: string;
|
||||
shareId: string;
|
||||
parentPageId: string;
|
||||
creatorId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
children: [];
|
||||
childrenIds: [];
|
||||
isLocked: boolean;
|
||||
status: string;
|
||||
publishedAt: Date;
|
||||
isPublic: boolean;
|
||||
lastModifiedById: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
position: string;
|
||||
hasChildren: boolean;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export interface IMovePage {
|
||||
|
||||
Reference in New Issue
Block a user