diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 5bfa52cf..b5ad0877 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -27,6 +27,8 @@ import Security from "@/ee/security/pages/security.tsx"; import License from "@/ee/licence/pages/license.tsx"; import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx"; import SharedPage from "@/pages/share/shared-page.tsx"; +import Shares from "@/pages/settings/shares/shares.tsx"; +import ShareLayout from "@/features/share/components/share-layout.tsx"; export default function App() { const { t } = useTranslation(); @@ -52,8 +54,10 @@ export default function App() { )} - } /> - } /> + }> + } /> + } /> + } /> @@ -82,6 +86,7 @@ export default function App() { } /> } /> } /> + } /> } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx index 4b5c0269..585fb249 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -111,7 +111,7 @@ export default function GlobalAppShell({ )} {isSettingsRoute ? ( - {children} + {children} ) : ( children )} diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx index f4cddfaa..2f3b46bd 100644 --- a/apps/client/src/components/settings/settings-queries.tsx +++ b/apps/client/src/components/settings/settings-queries.tsx @@ -8,7 +8,8 @@ import { getGroups } from "@/features/group/services/group-service.ts"; import { QueryParams } from "@/lib/types.ts"; import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts"; import { getLicenseInfo } from "@/ee/licence/services/license-service.ts"; -import { getSsoProviders } from '@/ee/security/services/security-service.ts'; +import { getSsoProviders } from "@/ee/security/services/security-service.ts"; +import { getShares } from "@/features/share/services/share-service.ts"; export const prefetchWorkspaceMembers = () => { const params = { limit: 100, page: 1, query: "" } as QueryParams; @@ -56,4 +57,11 @@ export const prefetchSsoProviders = () => { queryKey: ["sso-providers"], queryFn: () => getSsoProviders(), }); -}; \ No newline at end of file +}; + +export const prefetchShares = () => { + queryClient.prefetchQuery({ + queryKey: ["share-list", { page: 1 }], + queryFn: () => getShares({ page: 1, limit: 100 }), + }); +}; diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 16d94c64..ef586c37 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -10,8 +10,8 @@ import { IconBrush, IconCoin, IconLock, - IconKey, -} from "@tabler/icons-react"; + IconKey, IconWorld, +} from '@tabler/icons-react'; import { Link, useLocation, useNavigate } from "react-router-dom"; import classes from "./settings.module.css"; import { useTranslation } from "react-i18next"; @@ -22,11 +22,11 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { prefetchBilling, prefetchGroups, - prefetchLicense, + prefetchLicense, prefetchShares, prefetchSpaces, prefetchSsoProviders, prefetchWorkspaceMembers, -} from "@/components/settings/settings-queries.tsx"; +} from '@/components/settings/settings-queries.tsx'; import AppVersion from "@/components/settings/app-version.tsx"; interface DataItem { @@ -82,6 +82,8 @@ const groupedData: DataGroup[] = [ }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, + { label: "Sharing", icon: IconWorld, path: "/settings/shares" }, + ], }, { @@ -170,6 +172,9 @@ export default function SettingsSidebar() { case "Security & SSO": prefetchHandler = prefetchSsoProviders; break; + case "Sharing": + prefetchHandler = prefetchShares; + break; default: break; } diff --git a/apps/client/src/components/theme-toggle.module.css b/apps/client/src/components/theme-toggle.module.css new file mode 100644 index 00000000..936c5983 --- /dev/null +++ b/apps/client/src/components/theme-toggle.module.css @@ -0,0 +1,19 @@ +.dark { + @mixin dark { + display: none; + } + + @mixin light { + display: block; + } +} + +.light { + @mixin light { + display: none; + } + + @mixin dark { + display: block; + } +} diff --git a/apps/client/src/components/theme-toggle.tsx b/apps/client/src/components/theme-toggle.tsx index a988db6f..220155b5 100644 --- a/apps/client/src/components/theme-toggle.tsx +++ b/apps/client/src/components/theme-toggle.tsx @@ -1,13 +1,30 @@ -import { Button, Group, useMantineColorScheme } from '@mantine/core'; +import { + ActionIcon, + Tooltip, + useComputedColorScheme, + useMantineColorScheme, +} from "@mantine/core"; +import { IconMoon, IconSun } from "@tabler/icons-react"; +import classes from "./theme-toggle.module.css"; export function ThemeToggle() { - const { setColorScheme } = useMantineColorScheme(); + const { setColorScheme } = useMantineColorScheme(); + const computedColorScheme = useComputedColorScheme("light", { + getInitialValueInEffect: true, + }); - return ( - - - - - - ); + return ( + + + setColorScheme(computedColorScheme === "light" ? "dark" : "light") + } + aria-label="Toggle color scheme" + > + + + + + ); } diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index 6f54c057..d4f133f7 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -5,4 +5,6 @@ export const pageEditorAtom = atom(null); export const titleEditorAtom = atom(null); +export const readOnlyEditorAtom = atom(null); + export const yjsConnectionStatusAtom = atom(""); diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css index 9554a84d..739cc0d1 100644 --- a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css @@ -52,3 +52,8 @@ ) !important; } } + + +.leftBorder { + border-left: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); +} diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx index 6945a29b..37b50a0a 100644 --- a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; type TableOfContentsProps = { editor: ReturnType; + isShare?: boolean; }; export type HeadingLink = { @@ -73,6 +74,7 @@ export const TableOfContents: FC = (props) => { const handleUpdate = () => { const result = recalculateLinks(props.editor?.$nodes("heading")); + setLinks(result.links); setHeadingDOMNodes(result.nodes); }; @@ -85,9 +87,12 @@ export const TableOfContents: FC = (props) => { }; }, [props.editor]); - useEffect(() => { - handleUpdate(); - }, []); + useEffect( + () => { + handleUpdate(); + }, + props.isShare ? [props.editor] : [], + ); useEffect(() => { try { @@ -133,16 +138,18 @@ export const TableOfContents: FC = (props) => { if (!links.length) { return ( <> - - {t("Add headings (H1, H2, H3) to generate a table of contents.")} - + {!props.isShare && ( + + {t("Add headings (H1, H2, H3) to generate a table of contents.")} + + )} ); } return ( <> -
+
{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 ( + <> + + + + + + + + + }> + {t("Copy link")} + + + } + > + {t("Open page")} + + } + disabled={!isAdmin} + > + {t("Unshare")} + + + + + ); +} 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');