Share - WIP

This commit is contained in:
Philipinho
2025-04-15 12:45:26 +01:00
parent 8dff3e2240
commit 418e61614c
32 changed files with 820 additions and 52 deletions

View File

@ -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() {
</>
)}
<Route path={"/share/:shareId/:pageSlug"} element={<SharedPage />} />
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
<Route element={<ShareLayout />}>
<Route path={"/share/:shareId/:pageSlug"} element={<SharedPage />} />
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route>
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
@ -82,6 +86,7 @@ export default function App() {
<Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} />
<Route path={"shares"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}

View File

@ -111,7 +111,7 @@ export default function GlobalAppShell({
)}
<AppShell.Main>
{isSettingsRoute ? (
<Container size={800}>{children}</Container>
<Container size={850}>{children}</Container>
) : (
children
)}

View File

@ -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(),
});
};
};
export const prefetchShares = () => {
queryClient.prefetchQuery({
queryKey: ["share-list", { page: 1 }],
queryFn: () => getShares({ page: 1, limit: 100 }),
});
};

View File

@ -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;
}

View File

@ -0,0 +1,19 @@
.dark {
@mixin dark {
display: none;
}
@mixin light {
display: block;
}
}
.light {
@mixin light {
display: none;
}
@mixin dark {
display: block;
}
}

View File

@ -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 (
<Group justify="center" mt="xl">
<Button onClick={() => setColorScheme('light')}>Light</Button>
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
</Group>
);
return (
<Tooltip label="Toggle Color Scheme">
<ActionIcon
variant="default"
onClick={() =>
setColorScheme(computedColorScheme === "light" ? "dark" : "light")
}
aria-label="Toggle color scheme"
>
<IconSun className={classes.light} size={18} stroke={1.5} />
<IconMoon className={classes.dark} size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
);
}

View File

@ -5,4 +5,6 @@ export const pageEditorAtom = atom<Editor | null>(null);
export const titleEditorAtom = atom<Editor | null>(null);
export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");

View File

@ -52,3 +52,8 @@
) !important;
}
}
.leftBorder {
border-left: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}

View File

@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
type TableOfContentsProps = {
editor: ReturnType<typeof useEditor>;
isShare?: boolean;
};
export type HeadingLink = {
@ -73,6 +74,7 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
const handleUpdate = () => {
const result = recalculateLinks(props.editor?.$nodes("heading"));
setLinks(result.links);
setHeadingDOMNodes(result.nodes);
};
@ -85,9 +87,12 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
};
}, [props.editor]);
useEffect(() => {
handleUpdate();
}, []);
useEffect(
() => {
handleUpdate();
},
props.isShare ? [props.editor] : [],
);
useEffect(() => {
try {
@ -133,16 +138,18 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
if (!links.length) {
return (
<>
<Text size="sm">
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
</Text>
{!props.isShare && (
<Text size="sm">
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
</Text>
)}
</>
);
}
return (
<>
<div>
<div className={props.isShare ? classes.leftBorder : ""}>
{links.map((item, idx) => (
<Box<"button">
component="button"

View File

@ -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);
}
}}
></EditorProvider>
</>
);

View File

@ -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: [
["♥", ""],

View File

@ -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;

View File

@ -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: (
<Text size="sm">
{t("Are you sure you want to unshare this page?")}
</Text>
),
centered: true,
labels: { confirm: t("Unshare"), cancel: t("Don't") },
confirmProps: { color: "red" },
onConfirm: onRevoke,
});
return (
<>
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={copyLink} leftSection={<IconCopy size={16} />}>
{t("Copy link")}
</Menu.Item>
<Menu.Item
onClick={openPage}
leftSection={<IconFileDescription size={16} />}
>
{t("Open page")}
</Menu.Item>
<Menu.Item
c="red"
onClick={openRevokeModal}
leftSection={<IconTrash size={16} />}
disabled={!isAdmin}
>
{t("Unshare")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}

View File

@ -0,0 +1,10 @@
import { Outlet } from "react-router-dom";
import ShareShell from "@/features/share/components/share-shell.tsx";
export default function ShareLayout() {
return (
<ShareShell>
<Outlet />
</ShareShell>
);
}

View File

@ -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 (
<>
<Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Page")}</Table.Th>
<Table.Th>{t("Shared by")}</Table.Th>
<Table.Th>{t("Shared at")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((share: ISharedItem, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
target="_blank"
to={buildSharedPageUrl({
shareId: share.includeSubPages ? share.key : undefined,
pageTitle: share.page.title,
pageSlugId: share.page.slugId,
})}
>
<Group gap="4" wrap="nowrap">
{getPageIcon(share.page.icon)}
<div className={classes.shareLinkText}>
<Text fz="sm" fw={500} lineClamp={1}>
{share.page.title}
</Text>
</div>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={share.creator?.avatarUrl}
name={share.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{share.creator.name}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{format(new Date(share.createdAt), "MMM dd, yyyy")}
</Text>
</Table.Td>
<Table.Td>
<ShareActionMenu share={share} />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</>
);
}

View File

@ -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 (
<AppShell
header={{ height: 48 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !opened, desktop: false },
}}
aside={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: true, desktop: false },
}}
padding="md"
>
<AppShell.Header>
<Group wrap="nowrap" justify="space-between" p="sm">
<Burger opened={opened} onClick={toggle} size="sm" />
<ThemeToggle />
</Group>
</AppShell.Header>
{data?.pageTree?.length > 0 && (
<AppShell.Navbar p="md">
<MemoizedSharedTree sharedPageTree={data} />
</AppShell.Navbar>
)}
<AppShell.Main>
{children}
<Affix position={{ bottom: 20, right: 20 }}>
<Button
variant="default"
component="a"
target="_blank"
href="https://docmost.com"
>
Powered by Docmost
</Button>
</Affix>
</AppShell.Main>
<AppShell.Aside p="md" withBorder={false}>
<Text mb="md" fw={500}>
Table of contents
</Text>
<ScrollArea style={{ height: "80vh" }} scrollbarSize={5} type="scroll">
<div style={{ paddingBottom: "50px" }}>
{readOnlyEditor && (
<TableOfContents isShare={true} editor={readOnlyEditor} />
)}
</div>
</ScrollArea>
</AppShell.Aside>
</AppShell>
);
}

View File

@ -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;
}

View File

@ -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<OpenMap>({});
export default function SharedTree({ sharedPageTree }: SharedTree) {
const [tree, setTree] = useState<
TreeApi<SharedPageTreeNode> | null | undefined
>(null);
const rootElement = useRef<HTMLDivElement>();
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef);
const { pageSlug } = useParams();
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(
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 (
<div ref={mergedRef} className={classes.treeContainer}>
{rootElement.current && (
<Tree
initialData={treeData}
disableDrag={true}
disableDrop={true}
disableEdit={true}
width={width}
height={rootElement.current.clientHeight}
ref={(t) => 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}
</Tree>
)}
</div>
);
}
function Node({ node, style, tree }: NodeRendererProps<any>) {
const navigate = useNavigate();
const { shareId } = useParams();
const { t } = useTranslation();
const pageUrl = buildSharedPageUrl({
shareId: shareId,
pageSlugId: node.data.slugId,
pageTitle: node.data.name,
});
return (
<>
<Box
style={style}
className={clsx(classes.node, node.state, styles.treeNode)}
component={Link}
to={pageUrl}
>
<PageArrow node={node} />
<span className={classes.text}>{node.data.name || t("untitled")}</span>
</Box>
</>
);
}
interface PageArrowProps {
node: NodeApi<SpaceTreeNode>;
}
function PageArrow({ node }: PageArrowProps) {
return (
<ActionIcon
size={20}
variant="subtle"
c="gray"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
node.toggle();
}}
>
{node.isInternal ? (
node.children && (node.children.length > 0 || node.data.hasChildren) ? (
node.isOpen ? (
<IconChevronDown stroke={2} size={16} />
) : (
<IconChevronRight stroke={2} size={16} />
)
) : (
<IconPointFilled size={4} />
)
) : null}
</ActionIcon>
);
}

View File

@ -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<IPagination<ISharedItem>, Error> {
return useQuery({
queryKey: ["share-list"],
queryFn: () => getShares(params),
placeholderData: keepPreviousData,
});
}
export function useShareQuery(
shareInput: Partial<IShareInfoInput>,
@ -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<ISharedPageTree, Error> {
return useQuery({
queryKey: ["shared-page-tree", shareId],
queryFn: () => getSharedPageTree(shareId),
enabled: !!shareId,
placeholderData: keepPreviousData,
staleTime: 60 * 60 * 1000,
});
}

View File

@ -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<any> {
const req = await api.post<any>("/shares", data);
export async function getShares(
params?: QueryParams,
): Promise<IPagination<ISharedItem>> {
const req = await api.post("/shares", params);
return req.data;
}
@ -17,7 +22,7 @@ export async function createShare(data: ICreateShare): Promise<any> {
}
export async function getShareStatus(pageId: string): Promise<any> {
const req = await api.post<IPage>("/shares/status", { pageId });
const req = await api.post<any>("/shares/status", { pageId });
return req.data;
}
@ -31,10 +36,17 @@ export async function getShareInfo(
export async function updateShare(
data: Partial<IShareInfoInput>,
): Promise<any> {
const req = await api.post<IPage>("/shares/update", data);
const req = await api.post<any>("/shares/update", data);
return req.data;
}
export async function deleteShare(shareId: string): Promise<void> {
await api.post("/shares/delete", { shareId });
}
export async function getSharedPageTree(
shareId: string,
): Promise<ISharedPageTree> {
const req = await api.post<ISharedPageTree>("/shares/tree", { shareId });
return req.data;
}

View File

@ -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;
}
}
export interface ISharedPageTree {
share: IShare;
pageTree: Partial<IPage[]>;
}

View File

@ -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<IPage[]>): SharedPageTreeNode[] {
const pageMap: Record<string, SharedPageTreeNode> = {};
// 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);
}

View File

@ -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 (
<>
<Helmet>
<title>
{t("Shares")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Shares")} />
<ShareList />
</>
);
}

View File

@ -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() {
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
</Helmet>
<Container size={900} pt={50}>
<Container size={900}>
<ReadonlyPageEditor
key={page.id}
title={page.title}
content={page.content}
/>
</Container>
<Affix position={{ bottom: 20, right: 20 }}>
<Button variant="default">Powered by Docmost</Button>
</Affix>
</div>
);
}