+
{links.map((item, idx) => (
component="button"
diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx
index f6319509..2aff5be5 100644
--- a/apps/client/src/features/editor/readonly-page-editor.tsx
+++ b/apps/client/src/features/editor/readonly-page-editor.tsx
@@ -6,6 +6,12 @@ import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
+import { useAtom } from "jotai/index";
+import {
+ pageEditorAtom,
+ readOnlyEditorAtom,
+} from "@/features/editor/atoms/editor-atoms.ts";
+import { Editor } from "@tiptap/core";
interface PageEditorProps {
title: string;
@@ -16,6 +22,8 @@ export default function ReadonlyPageEditor({
title,
content,
}: PageEditorProps) {
+ const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
+
const extensions = useMemo(() => {
return [...mainExtensions];
}, []);
@@ -46,6 +54,12 @@ export default function ReadonlyPageEditor({
immediatelyRender={true}
extensions={extensions}
content={content}
+ onCreate={({ editor }) => {
+ if (editor) {
+ // @ts-ignore
+ setReadOnlyEditor(editor);
+ }
+ }}
>
>
);
diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts
index b42aba0f..e60df520 100644
--- a/apps/client/src/features/page/page.utils.ts
+++ b/apps/client/src/features/page/page.utils.ts
@@ -1,9 +1,6 @@
import slugify from "@sindresorhus/slugify";
-export const buildPageSlug = (
- pageSlugId: string,
- pageTitle?: string,
-): string => {
+const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", {
customReplacements: [
["♥", ""],
diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts
index 0dfe8ed4..7ae84e38 100644
--- a/apps/client/src/features/page/tree/utils/utils.ts
+++ b/apps/client/src/features/page/tree/utils/utils.ts
@@ -1,7 +1,7 @@
import { IPage } from "@/features/page/types/page.types.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
-function sortPositionKeys(keys: any[]) {
+export function sortPositionKeys(keys: any[]) {
return keys.sort((a, b) => {
if (a.position < b.position) return -1;
if (a.position > b.position) return 1;
diff --git a/apps/client/src/features/share/components/share-action-menu.tsx b/apps/client/src/features/share/components/share-action-menu.tsx
new file mode 100644
index 00000000..ae13d7fe
--- /dev/null
+++ b/apps/client/src/features/share/components/share-action-menu.tsx
@@ -0,0 +1,106 @@
+import { Menu, ActionIcon, Text } from "@mantine/core";
+import React from "react";
+import {
+ IconCopy,
+ IconDots,
+ IconFileDescription,
+ IconTrash,
+} from "@tabler/icons-react";
+import { modals } from "@mantine/modals";
+import { useTranslation } from "react-i18next";
+import useUserRole from "@/hooks/use-user-role.tsx";
+import { ISharedItem } from "@/features/share/types/share.types.ts";
+import {
+ buildPageUrl,
+ buildSharedPageUrl,
+} from "@/features/page/page.utils.ts";
+import { useClipboard } from "@mantine/hooks";
+import { notifications } from "@mantine/notifications";
+import { useNavigate } from "react-router-dom";
+
+interface Props {
+ share: ISharedItem;
+}
+export default function ShareActionMenu({ share }: Props) {
+ const { t } = useTranslation();
+ const { isAdmin } = useUserRole();
+ const navigate = useNavigate();
+ const clipboard = useClipboard();
+
+ const openPage = () => {
+ const pageLink = buildPageUrl(
+ share.space.slug,
+ share.page.slugId,
+ share.page.title,
+ );
+ navigate(pageLink);
+ };
+
+ const copyLink = () => {
+ const shareLink = buildSharedPageUrl({
+ shareId: share.includeSubPages ? share.key : undefined,
+ pageTitle: share.page.title,
+ pageSlugId: share.page.slugId,
+ });
+
+ clipboard.copy(shareLink);
+ notifications.show({ message: t("Link copied") });
+ };
+ const onRevoke = async () => {
+ //
+ };
+
+ const openRevokeModal = () =>
+ modals.openConfirmModal({
+ title: t("Unshare page"),
+ children: (
+
+ {t("Are you sure you want to unshare this page?")}
+
+ ),
+ centered: true,
+ labels: { confirm: t("Unshare"), cancel: t("Don't") },
+ confirmProps: { color: "red" },
+ onConfirm: onRevoke,
+ });
+
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/apps/client/src/features/share/components/share-layout.tsx b/apps/client/src/features/share/components/share-layout.tsx
new file mode 100644
index 00000000..e3b2eb17
--- /dev/null
+++ b/apps/client/src/features/share/components/share-layout.tsx
@@ -0,0 +1,10 @@
+import { Outlet } from "react-router-dom";
+import ShareShell from "@/features/share/components/share-shell.tsx";
+
+export default function ShareLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/client/src/features/share/components/share-list.tsx b/apps/client/src/features/share/components/share-list.tsx
new file mode 100644
index 00000000..b76de332
--- /dev/null
+++ b/apps/client/src/features/share/components/share-list.tsx
@@ -0,0 +1,97 @@
+import { Table, Group, Text, Anchor } from "@mantine/core";
+import React, { useState } from "react";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import Paginate from "@/components/common/paginate.tsx";
+import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
+import { ISharedItem } from "@/features/share/types/share.types.ts";
+import { format } from "date-fns";
+import ShareActionMenu from "@/features/share/components/share-action-menu.tsx";
+import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
+import { getPageIcon } from "@/lib";
+import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
+import classes from "./share.module.css";
+
+export default function ShareList() {
+ const { t } = useTranslation();
+ const [page, setPage] = useState(1);
+ const { data, isLoading } = useGetSharesQuery({ page });
+
+ return (
+ <>
+
+
+
+
+ {t("Page")}
+ {t("Shared by")}
+ {t("Shared at")}
+
+
+
+
+ {data?.items.map((share: ISharedItem, index: number) => (
+
+
+
+
+ {getPageIcon(share.page.icon)}
+
+
+ {share.page.title}
+
+
+
+
+
+
+
+
+
+ {share.creator.name}
+
+
+
+
+
+ {format(new Date(share.createdAt), "MMM dd, yyyy")}
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {data?.items.length > 0 && (
+
+ )}
+ >
+ );
+}
diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx
new file mode 100644
index 00000000..11e4934a
--- /dev/null
+++ b/apps/client/src/features/share/components/share-shell.tsx
@@ -0,0 +1,90 @@
+import React from "react";
+import {
+ Affix,
+ AppShell,
+ Burger,
+ Button,
+ Group,
+ ScrollArea,
+ Text,
+} from "@mantine/core";
+import { useDisclosure } from "@mantine/hooks";
+import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts";
+import { useParams } from "react-router-dom";
+import SharedTree from "@/features/share/components/shared-tree.tsx";
+import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
+import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
+import { ThemeToggle } from "@/components/theme-toggle.tsx";
+import { useAtomValue } from "jotai";
+
+const MemoizedSharedTree = React.memo(SharedTree);
+
+export default function ShareShell({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [opened, { toggle }] = useDisclosure();
+ const { shareId } = useParams();
+ const { data } = useGetSharedPageTreeQuery(shareId);
+ const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
+
+ return (
+
+
+
+
+
+
+
+
+ {data?.pageTree?.length > 0 && (
+
+
+
+ )}
+
+
+ {children}
+
+
+
+
+
+
+
+
+ Table of contents
+
+
+
+
+ {readOnlyEditor && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/share/components/share.module.css b/apps/client/src/features/share/components/share.module.css
new file mode 100644
index 00000000..5293df37
--- /dev/null
+++ b/apps/client/src/features/share/components/share.module.css
@@ -0,0 +1,13 @@
+.shareLinkText {
+ @mixin light {
+ border-bottom: 0.05em solid var(--mantine-color-dark-0);
+ }
+ @mixin dark {
+ border-bottom: 0.05em solid var(--mantine-color-dark-2);
+ }
+}
+
+.treeNode {
+ text-decoration: none;
+ user-select: none;
+}
\ No newline at end of file
diff --git a/apps/client/src/features/share/components/shared-tree.tsx b/apps/client/src/features/share/components/shared-tree.tsx
new file mode 100644
index 00000000..37a359fb
--- /dev/null
+++ b/apps/client/src/features/share/components/shared-tree.tsx
@@ -0,0 +1,165 @@
+import { ISharedPageTree } from "@/features/share/types/share.types.ts";
+import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
+import {
+ buildSharedPageTree,
+ SharedPageTreeNode,
+} from "@/features/share/utils.ts";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useElementSize, useMergedRef } from "@mantine/hooks";
+import { SpaceTreeNode } from "@/features/page/tree/types.ts";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { atom, useAtom } from "jotai/index";
+import { useTranslation } from "react-i18next";
+import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
+import clsx from "clsx";
+import {
+ IconChevronDown,
+ IconChevronRight,
+ IconPointFilled,
+} from "@tabler/icons-react";
+import { ActionIcon, Box } from "@mantine/core";
+import { extractPageSlugId } from "@/lib";
+import { OpenMap } from "react-arborist/dist/main/state/open-slice";
+import classes from "@/features/page/tree/styles/tree.module.css";
+import styles from "./share.module.css";
+
+interface SharedTree {
+ sharedPageTree: ISharedPageTree;
+}
+
+const openSharedTreeNodesAtom = atom({});
+
+export default function SharedTree({ sharedPageTree }: SharedTree) {
+ const [tree, setTree] = useState<
+ TreeApi | null | undefined
+ >(null);
+ const rootElement = useRef();
+ const { ref: sizeRef, width, height } = useElementSize();
+ const mergedRef = useMergedRef(rootElement, sizeRef);
+ const { pageSlug } = useParams();
+ const [openTreeNodes, setOpenTreeNodes] = useAtom(
+ openSharedTreeNodesAtom,
+ );
+ const currentNodeId = extractPageSlugId(pageSlug);
+
+ const treeData: SharedPageTreeNode[] = useMemo(() => {
+ if (!sharedPageTree?.pageTree) return;
+ return buildSharedPageTree(sharedPageTree.pageTree);
+ }, [sharedPageTree?.pageTree]);
+
+ useEffect(() => {
+ const parentNodeId = treeData?.[0]?.slugId;
+
+ if (parentNodeId && tree) {
+ setTimeout(() => {
+ tree.openSiblings(tree.get(parentNodeId));
+ });
+
+ // open direct children of parent node
+ tree.get(parentNodeId).children.forEach((node) => {
+ tree.openSiblings(node);
+ });
+ }
+ }, [treeData, tree]);
+
+ useEffect(() => {
+ if (currentNodeId && tree) {
+ setTimeout(() => {
+ // focus on node and open all parents
+ tree?.select(currentNodeId, { align: "auto" });
+ }, 200);
+ } else {
+ tree?.deselectAll();
+ }
+ }, [currentNodeId, tree]);
+
+ if (!sharedPageTree || !sharedPageTree?.pageTree) {
+ return null;
+ }
+
+ return (
+
+ {rootElement.current && (
+ setTree(t)}
+ openByDefault={false}
+ disableMultiSelection={true}
+ className={classes.tree}
+ rowClassName={classes.row}
+ rowHeight={30}
+ overscanCount={10}
+ dndRootElement={rootElement.current}
+ onToggle={() => {
+ setOpenTreeNodes(tree?.openState);
+ }}
+ initialOpenState={openTreeNodes}
+ >
+ {Node}
+
+ )}
+
+ );
+}
+
+function Node({ node, style, tree }: NodeRendererProps) {
+ const navigate = useNavigate();
+ const { shareId } = useParams();
+ const { t } = useTranslation();
+
+ const pageUrl = buildSharedPageUrl({
+ shareId: shareId,
+ pageSlugId: node.data.slugId,
+ pageTitle: node.data.name,
+ });
+
+ return (
+ <>
+
+
+ {node.data.name || t("untitled")}
+
+ >
+ );
+}
+
+interface PageArrowProps {
+ node: NodeApi;
+}
+
+function PageArrow({ node }: PageArrowProps) {
+ return (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ node.toggle();
+ }}
+ >
+ {node.isInternal ? (
+ node.children && (node.children.length > 0 || node.data.hasChildren) ? (
+ node.isOpen ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )
+ ) : null}
+
+ );
+}
diff --git a/apps/client/src/features/share/queries/share-query.ts b/apps/client/src/features/share/queries/share-query.ts
index 3d4e74d2..a8c5d152 100644
--- a/apps/client/src/features/share/queries/share-query.ts
+++ b/apps/client/src/features/share/queries/share-query.ts
@@ -1,19 +1,38 @@
-import { useMutation, useQuery, UseQueryResult } from "@tanstack/react-query";
+import {
+ keepPreviousData,
+ useMutation,
+ useQuery,
+ UseQueryResult,
+} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
-import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
import {
ICreateShare,
+ ISharedItem,
+ ISharedPageTree,
IShareInfoInput,
} from "@/features/share/types/share.types.ts";
import {
createShare,
deleteShare,
+ getSharedPageTree,
getShareInfo,
+ getShares,
getShareStatus,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
+import { IPagination, QueryParams } from "@/lib/types.ts";
+
+export function useGetSharesQuery(
+ params?: QueryParams,
+): UseQueryResult, Error> {
+ return useQuery({
+ queryKey: ["share-list"],
+ queryFn: () => getShares(params),
+ placeholderData: keepPreviousData,
+ });
+}
export function useShareQuery(
shareInput: Partial,
@@ -22,7 +41,6 @@ export function useShareQuery(
queryKey: ["shares", shareInput],
queryFn: () => getShareInfo(shareInput),
enabled: !!shareInput.pageId,
- staleTime: 5 * 60 * 1000,
});
return query;
@@ -73,3 +91,15 @@ export function useDeleteShareMutation() {
},
});
}
+
+export function useGetSharedPageTreeQuery(
+ shareId: string,
+): UseQueryResult {
+ return useQuery({
+ queryKey: ["shared-page-tree", shareId],
+ queryFn: () => getSharedPageTree(shareId),
+ enabled: !!shareId,
+ placeholderData: keepPreviousData,
+ staleTime: 60 * 60 * 1000,
+ });
+}
diff --git a/apps/client/src/features/share/services/share-service.ts b/apps/client/src/features/share/services/share-service.ts
index efa28a4a..747ac64d 100644
--- a/apps/client/src/features/share/services/share-service.ts
+++ b/apps/client/src/features/share/services/share-service.ts
@@ -3,11 +3,16 @@ import { IPage } from "@/features/page/types/page.types";
import {
ICreateShare,
+ ISharedItem,
+ ISharedPageTree,
IShareInfoInput,
} from "@/features/share/types/share.types.ts";
+import { IPagination, QueryParams } from "@/lib/types.ts";
-export async function getShares(data: ICreateShare): Promise {
- const req = await api.post("/shares", data);
+export async function getShares(
+ params?: QueryParams,
+): Promise> {
+ const req = await api.post("/shares", params);
return req.data;
}
@@ -17,7 +22,7 @@ export async function createShare(data: ICreateShare): Promise {
}
export async function getShareStatus(pageId: string): Promise {
- const req = await api.post("/shares/status", { pageId });
+ const req = await api.post("/shares/status", { pageId });
return req.data;
}
@@ -31,10 +36,17 @@ export async function getShareInfo(
export async function updateShare(
data: Partial,
): Promise {
- const req = await api.post("/shares/update", data);
+ const req = await api.post("/shares/update", data);
return req.data;
}
export async function deleteShare(shareId: string): Promise {
await api.post("/shares/delete", { shareId });
}
+
+export async function getSharedPageTree(
+ shareId: string,
+): Promise {
+ const req = await api.post("/shares/tree", { shareId });
+ return req.data;
+}
diff --git a/apps/client/src/features/share/types/share.types.ts b/apps/client/src/features/share/types/share.types.ts
index f84131f8..633fcb6a 100644
--- a/apps/client/src/features/share/types/share.types.ts
+++ b/apps/client/src/features/share/types/share.types.ts
@@ -1,3 +1,37 @@
+import { IPage } from "@/features/page/types/page.types.ts";
+
+export interface IShare {
+ id: string;
+ key: string;
+ pageId: string;
+ includeSubPages: boolean;
+ creatorId: string;
+ spaceId: string;
+ workspaceId: string;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+}
+
+export interface ISharedItem extends IShare {
+ page: {
+ id: string;
+ title: string;
+ slugId: string;
+ icon: string | null;
+ };
+ space: {
+ id: string;
+ name: string;
+ slug: string;
+ };
+ creator: {
+ id: string;
+ name: string;
+ avatarUrl: string | null;
+ };
+}
+
export interface ICreateShare {
pageId: string;
includeSubPages?: boolean;
@@ -5,4 +39,9 @@ export interface ICreateShare {
export interface IShareInfoInput {
pageId: string;
-}
\ No newline at end of file
+}
+
+export interface ISharedPageTree {
+ share: IShare;
+ pageTree: Partial;
+}
diff --git a/apps/client/src/features/share/utils.ts b/apps/client/src/features/share/utils.ts
new file mode 100644
index 00000000..74ec349f
--- /dev/null
+++ b/apps/client/src/features/share/utils.ts
@@ -0,0 +1,60 @@
+import { IPage } from "@/features/page/types/page.types.ts";
+import { sortPositionKeys } from "@/features/page/tree/utils";
+
+export type SharedPageTreeNode = {
+ id: string;
+ slugId: string;
+ name: string;
+ icon?: string;
+ position: string;
+ spaceId: string;
+ parentPageId: string;
+ hasChildren: boolean;
+ children: SharedPageTreeNode[];
+ label: string,
+ value: string,
+};
+
+export function buildSharedPageTree(pages: Partial): SharedPageTreeNode[] {
+ const pageMap: Record = {};
+
+ // Initialize each page as a tree node and store it in a map.
+ pages.forEach((page) => {
+ pageMap[page.id] = {
+ id: page.slugId,
+ slugId: page.slugId,
+ name: page.title,
+ icon: page.icon,
+ position: page.position,
+ // Initially assume a page has no children.
+ hasChildren: false,
+ spaceId: page.spaceId,
+ parentPageId: page.parentPageId,
+ label: page.title || 'untitled',
+ value: page.id,
+ children: [],
+ };
+ });
+
+ // Build the tree structure.
+ const tree: SharedPageTreeNode[] = [];
+ pages.forEach((page) => {
+ if (page.parentPageId) {
+ // If the page has a parent, add it as a child of the parent node.
+ const parentNode = pageMap[page.parentPageId];
+ if (parentNode) {
+ parentNode.children.push(pageMap[page.id]);
+ parentNode.hasChildren = true;
+ } else {
+ // Parent not found – treat this page as a top-level node.
+ tree.push(pageMap[page.id]);
+ }
+ } else {
+ // No parentPageId indicates a top-level page.
+ tree.push(pageMap[page.id]);
+ }
+ });
+
+ // Return the sorted tree.
+ return sortPositionKeys(tree);
+}
diff --git a/apps/client/src/pages/settings/shares/shares.tsx b/apps/client/src/pages/settings/shares/shares.tsx
new file mode 100644
index 00000000..f071737a
--- /dev/null
+++ b/apps/client/src/pages/settings/shares/shares.tsx
@@ -0,0 +1,21 @@
+import SettingsTitle from "@/components/settings/settings-title.tsx";
+import { Helmet } from "react-helmet-async";
+import { getAppName } from "@/lib/config.ts";
+import { useTranslation } from "react-i18next";
+import ShareList from "@/features/share/components/share-list.tsx";
+
+export default function Shares() {
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+
+ {t("Shares")} - {getAppName()}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx
index 6c44d2ff..5e897e59 100644
--- a/apps/client/src/pages/share/shared-page.tsx
+++ b/apps/client/src/pages/share/shared-page.tsx
@@ -2,7 +2,7 @@ import { useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useShareQuery } from "@/features/share/queries/share-query.ts";
-import { Affix, Button, Container } from "@mantine/core";
+import { Container } from "@mantine/core";
import React from "react";
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
import { extractPageSlugId } from "@/lib";
@@ -36,17 +36,13 @@ export default function SingleSharedPage() {
{`${page?.icon || ""} ${page?.title || t("untitled")}`}
-
+
-
-
-
-
);
}
diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts
index 43d8f1d2..e862bb3e 100644
--- a/apps/server/src/core/page/services/page.service.ts
+++ b/apps/server/src/core/page/services/page.service.ts
@@ -212,7 +212,7 @@ export class PageService {
trx,
);
const pageIds = await this.pageRepo
- .getPageAndDescendants(rootPage.id)
+ .getPageAndDescendants(rootPage.id, { includeContent: false })
.then((pages) => pages.map((page) => page.id));
// The first id is the root page id
if (pageIds.length > 1) {
diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts
index 2eacaba3..cba63bdf 100644
--- a/apps/server/src/core/share/share.controller.ts
+++ b/apps/server/src/core/share/share.controller.ts
@@ -140,4 +140,14 @@ export class ShareController {
await this.shareRepo.deleteShare(share.id);
}
+
+ @Public()
+ @HttpCode(HttpStatus.OK)
+ @Post('/tree')
+ async getSharePageTree(
+ @Body() dto: ShareIdDto,
+ @AuthWorkspace() workspace: Workspace,
+ ) {
+ return this.shareService.getShareTree(dto.shareId, workspace.id);
+ }
}
diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts
index 2fe70d94..4994a3e5 100644
--- a/apps/server/src/core/share/share.service.ts
+++ b/apps/server/src/core/share/share.service.ts
@@ -34,6 +34,23 @@ export class ShareService {
private readonly tokenService: TokenService,
) {}
+ async getShareTree(shareId: string, workspaceId: string) {
+ const share = await this.shareRepo.findById(shareId);
+ if (!share || share.workspaceId !== workspaceId) {
+ throw new NotFoundException('Share not found');
+ }
+
+ if (share.includeSubPages) {
+ const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
+ includeContent: false,
+ });
+
+ return { share, pageTree: pageList };
+ } else {
+ return { share, pageTree: [] };
+ }
+ }
+
async createShare(opts: {
authUserId: string;
workspaceId: string;
diff --git a/apps/server/src/database/migrations/20250408T191830-shares.ts b/apps/server/src/database/migrations/20250408T191830-shares.ts
index 439516c9..81ddcb16 100644
--- a/apps/server/src/database/migrations/20250408T191830-shares.ts
+++ b/apps/server/src/database/migrations/20250408T191830-shares.ts
@@ -26,7 +26,10 @@ export async function up(db: Kysely
): Promise {
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
- .addUniqueConstraint('shares_key_unique', ['key'])
+ .addUniqueConstraint('shares_key_workspace_id_unique', [
+ 'key',
+ 'workspace_id',
+ ])
.execute();
}
diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts
index 850fb2d1..8f06c4d4 100644
--- a/apps/server/src/database/repos/page/page.repo.ts
+++ b/apps/server/src/database/repos/page/page.repo.ts
@@ -211,7 +211,10 @@ export class PageRepo {
).as('contributors');
}
- async getPageAndDescendants(parentPageId: string) {
+ async getPageAndDescendants(
+ parentPageId: string,
+ opts: { includeContent: boolean },
+ ) {
return this.db
.withRecursive('page_hierarchy', (db) =>
db
@@ -221,11 +224,12 @@ export class PageRepo {
'slugId',
'title',
'icon',
- 'content',
+ 'position',
'parentPageId',
'spaceId',
'workspaceId',
])
+ .$if(opts?.includeContent, (qb) => qb.select('content'))
.where('id', '=', parentPageId)
.unionAll((exp) =>
exp
@@ -235,11 +239,12 @@ export class PageRepo {
'p.slugId',
'p.title',
'p.icon',
- 'p.content',
+ 'p.position',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
])
+ .$if(opts?.includeContent, (qb) => qb.select('content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
),
)
diff --git a/apps/server/src/database/repos/share/share.repo.ts b/apps/server/src/database/repos/share/share.repo.ts
index 193ca901..db8fb9e1 100644
--- a/apps/server/src/database/repos/share/share.repo.ts
+++ b/apps/server/src/database/repos/share/share.repo.ts
@@ -131,6 +131,7 @@ export class ShareRepo {
const query = this.db
.selectFrom('shares')
.select(this.baseFields)
+ .select((eb) => this.withPage(eb))
.select((eb) => this.withSpace(eb))
.select((eb) => this.withCreator(eb))
.where('spaceId', 'in', userSpaceIds)
@@ -146,6 +147,15 @@ export class ShareRepo {
return result;
}
+ withPage(eb: ExpressionBuilder) {
+ return jsonObjectFrom(
+ eb
+ .selectFrom('pages')
+ .select(['pages.id', 'pages.title', 'pages.slugId', 'pages.icon'])
+ .whereRef('pages.id', '=', 'shares.pageId'),
+ ).as('page');
+ }
+
withSpace(eb: ExpressionBuilder) {
return jsonObjectFrom(
eb
diff --git a/apps/server/src/ee b/apps/server/src/ee
index a04fcc22..d3095f2d 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit a04fcc224e36514741f064d83a3c39df31766b65
+Subproject commit d3095f2d8bd2870da7f3b534c83c84e8fb3099bc
diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts
index a5f00ba4..4506811a 100644
--- a/apps/server/src/integrations/export/export.service.ts
+++ b/apps/server/src/integrations/export/export.service.ts
@@ -27,7 +27,10 @@ import { EditorState } from '@tiptap/pm/state';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import slugify = require('@sindresorhus/slugify');
import { EnvironmentService } from '../environment/environment.service';
-import { getAttachmentIds, getProsemirrorContent } from '../../common/helpers/prosemirror/utils';
+import {
+ getAttachmentIds,
+ getProsemirrorContent,
+} from '../../common/helpers/prosemirror/utils';
@Injectable()
export class ExportService {
@@ -87,7 +90,9 @@ export class ExportService {
}
async exportPageWithChildren(pageId: string, format: string) {
- const pages = await this.pageRepo.getPageAndDescendants(pageId);
+ const pages = await this.pageRepo.getPageAndDescendants(pageId, {
+ includeContent: true,
+ });
if (!pages || pages.length === 0) {
throw new BadRequestException('No pages to export');