mirror of
https://github.com/docmost/docmost.git
synced 2025-11-18 18:41:11 +10:00
Share - WIP
This commit is contained in:
@ -27,6 +27,8 @@ import Security from "@/ee/security/pages/security.tsx";
|
|||||||
import License from "@/ee/licence/pages/license.tsx";
|
import License from "@/ee/licence/pages/license.tsx";
|
||||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||||
import SharedPage from "@/pages/share/shared-page.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() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -52,8 +54,10 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Route path={"/share/:shareId/:pageSlug"} element={<SharedPage />} />
|
<Route element={<ShareLayout />}>
|
||||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
<Route path={"/share/:shareId/:pageSlug"} element={<SharedPage />} />
|
||||||
|
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||||
|
|
||||||
@ -82,6 +86,7 @@ export default function App() {
|
|||||||
<Route path={"groups"} element={<Groups />} />
|
<Route path={"groups"} element={<Groups />} />
|
||||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||||
<Route path={"spaces"} element={<Spaces />} />
|
<Route path={"spaces"} element={<Spaces />} />
|
||||||
|
<Route path={"shares"} element={<Shares />} />
|
||||||
<Route path={"security"} element={<Security />} />
|
<Route path={"security"} element={<Security />} />
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
|
|||||||
@ -111,7 +111,7 @@ export default function GlobalAppShell({
|
|||||||
)}
|
)}
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
{isSettingsRoute ? (
|
{isSettingsRoute ? (
|
||||||
<Container size={800}>{children}</Container>
|
<Container size={850}>{children}</Container>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import { getGroups } from "@/features/group/services/group-service.ts";
|
|||||||
import { QueryParams } from "@/lib/types.ts";
|
import { QueryParams } from "@/lib/types.ts";
|
||||||
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import { getLicenseInfo } from "@/ee/licence/services/license-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 = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
||||||
@ -56,4 +57,11 @@ export const prefetchSsoProviders = () => {
|
|||||||
queryKey: ["sso-providers"],
|
queryKey: ["sso-providers"],
|
||||||
queryFn: () => getSsoProviders(),
|
queryFn: () => getSsoProviders(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const prefetchShares = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["share-list", { page: 1 }],
|
||||||
|
queryFn: () => getShares({ page: 1, limit: 100 }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -10,8 +10,8 @@ import {
|
|||||||
IconBrush,
|
IconBrush,
|
||||||
IconCoin,
|
IconCoin,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconKey,
|
IconKey, IconWorld,
|
||||||
} from "@tabler/icons-react";
|
} from '@tabler/icons-react';
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -22,11 +22,11 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|||||||
import {
|
import {
|
||||||
prefetchBilling,
|
prefetchBilling,
|
||||||
prefetchGroups,
|
prefetchGroups,
|
||||||
prefetchLicense,
|
prefetchLicense, prefetchShares,
|
||||||
prefetchSpaces,
|
prefetchSpaces,
|
||||||
prefetchSsoProviders,
|
prefetchSsoProviders,
|
||||||
prefetchWorkspaceMembers,
|
prefetchWorkspaceMembers,
|
||||||
} from "@/components/settings/settings-queries.tsx";
|
} from '@/components/settings/settings-queries.tsx';
|
||||||
import AppVersion from "@/components/settings/app-version.tsx";
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
|
|
||||||
interface DataItem {
|
interface DataItem {
|
||||||
@ -82,6 +82,8 @@ const groupedData: DataGroup[] = [
|
|||||||
},
|
},
|
||||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ 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":
|
case "Security & SSO":
|
||||||
prefetchHandler = prefetchSsoProviders;
|
prefetchHandler = prefetchSsoProviders;
|
||||||
break;
|
break;
|
||||||
|
case "Sharing":
|
||||||
|
prefetchHandler = prefetchShares;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
19
apps/client/src/components/theme-toggle.module.css
Normal file
19
apps/client/src/components/theme-toggle.module.css
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
.dark {
|
||||||
|
@mixin dark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
@mixin light {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() {
|
export function ThemeToggle() {
|
||||||
const { setColorScheme } = useMantineColorScheme();
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
|
const computedColorScheme = useComputedColorScheme("light", {
|
||||||
|
getInitialValueInEffect: true,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="center" mt="xl">
|
<Tooltip label="Toggle Color Scheme">
|
||||||
<Button onClick={() => setColorScheme('light')}>Light</Button>
|
<ActionIcon
|
||||||
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
|
variant="default"
|
||||||
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
|
onClick={() =>
|
||||||
</Group>
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,4 +5,6 @@ export const pageEditorAtom = atom<Editor | null>(null);
|
|||||||
|
|
||||||
export const titleEditorAtom = atom<Editor | null>(null);
|
export const titleEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
|
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
export const yjsConnectionStatusAtom = atom<string>("");
|
export const yjsConnectionStatusAtom = atom<string>("");
|
||||||
|
|||||||
@ -52,3 +52,8 @@
|
|||||||
) !important;
|
) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.leftBorder {
|
||||||
|
border-left: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
type TableOfContentsProps = {
|
type TableOfContentsProps = {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
|
isShare?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HeadingLink = {
|
export type HeadingLink = {
|
||||||
@ -73,6 +74,7 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
||||||
|
|
||||||
setLinks(result.links);
|
setLinks(result.links);
|
||||||
setHeadingDOMNodes(result.nodes);
|
setHeadingDOMNodes(result.nodes);
|
||||||
};
|
};
|
||||||
@ -85,9 +87,12 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
};
|
};
|
||||||
}, [props.editor]);
|
}, [props.editor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(
|
||||||
handleUpdate();
|
() => {
|
||||||
}, []);
|
handleUpdate();
|
||||||
|
},
|
||||||
|
props.isShare ? [props.editor] : [],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@ -133,16 +138,18 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
if (!links.length) {
|
if (!links.length) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text size="sm">
|
{!props.isShare && (
|
||||||
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
|
<Text size="sm">
|
||||||
</Text>
|
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div className={props.isShare ? classes.leftBorder : ""}>
|
||||||
{links.map((item, idx) => (
|
{links.map((item, idx) => (
|
||||||
<Box<"button">
|
<Box<"button">
|
||||||
component="button"
|
component="button"
|
||||||
|
|||||||
@ -6,6 +6,12 @@ import { Document } from "@tiptap/extension-document";
|
|||||||
import { Heading } from "@tiptap/extension-heading";
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
import { Text } from "@tiptap/extension-text";
|
import { Text } from "@tiptap/extension-text";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
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 {
|
interface PageEditorProps {
|
||||||
title: string;
|
title: string;
|
||||||
@ -16,6 +22,8 @@ export default function ReadonlyPageEditor({
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
|
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
return [...mainExtensions];
|
return [...mainExtensions];
|
||||||
}, []);
|
}, []);
|
||||||
@ -46,6 +54,12 @@ export default function ReadonlyPageEditor({
|
|||||||
immediatelyRender={true}
|
immediatelyRender={true}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
content={content}
|
content={content}
|
||||||
|
onCreate={({ editor }) => {
|
||||||
|
if (editor) {
|
||||||
|
// @ts-ignore
|
||||||
|
setReadOnlyEditor(editor);
|
||||||
|
}
|
||||||
|
}}
|
||||||
></EditorProvider>
|
></EditorProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import slugify from "@sindresorhus/slugify";
|
import slugify from "@sindresorhus/slugify";
|
||||||
|
|
||||||
export const buildPageSlug = (
|
const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
|
||||||
pageSlugId: string,
|
|
||||||
pageTitle?: string,
|
|
||||||
): string => {
|
|
||||||
const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", {
|
const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", {
|
||||||
customReplacements: [
|
customReplacements: [
|
||||||
["♥", ""],
|
["♥", ""],
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
|
|
||||||
function sortPositionKeys(keys: any[]) {
|
export function sortPositionKeys(keys: any[]) {
|
||||||
return keys.sort((a, b) => {
|
return keys.sort((a, b) => {
|
||||||
if (a.position < b.position) return -1;
|
if (a.position < b.position) return -1;
|
||||||
if (a.position > b.position) return 1;
|
if (a.position > b.position) return 1;
|
||||||
|
|||||||
106
apps/client/src/features/share/components/share-action-menu.tsx
Normal file
106
apps/client/src/features/share/components/share-action-menu.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/client/src/features/share/components/share-layout.tsx
Normal file
10
apps/client/src/features/share/components/share-layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/client/src/features/share/components/share-list.tsx
Normal file
97
apps/client/src/features/share/components/share-list.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/client/src/features/share/components/share-shell.tsx
Normal file
90
apps/client/src/features/share/components/share-shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/client/src/features/share/components/share.module.css
Normal file
13
apps/client/src/features/share/components/share.module.css
Normal 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;
|
||||||
|
}
|
||||||
165
apps/client/src/features/share/components/shared-tree.tsx
Normal file
165
apps/client/src/features/share/components/shared-tree.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 { notifications } from "@mantine/notifications";
|
||||||
import { validate as isValidUuid } from "uuid";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ICreateShare,
|
ICreateShare,
|
||||||
|
ISharedItem,
|
||||||
|
ISharedPageTree,
|
||||||
IShareInfoInput,
|
IShareInfoInput,
|
||||||
} from "@/features/share/types/share.types.ts";
|
} from "@/features/share/types/share.types.ts";
|
||||||
import {
|
import {
|
||||||
createShare,
|
createShare,
|
||||||
deleteShare,
|
deleteShare,
|
||||||
|
getSharedPageTree,
|
||||||
getShareInfo,
|
getShareInfo,
|
||||||
|
getShares,
|
||||||
getShareStatus,
|
getShareStatus,
|
||||||
updateShare,
|
updateShare,
|
||||||
} from "@/features/share/services/share-service.ts";
|
} from "@/features/share/services/share-service.ts";
|
||||||
import { IPage } from "@/features/page/types/page.types.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(
|
export function useShareQuery(
|
||||||
shareInput: Partial<IShareInfoInput>,
|
shareInput: Partial<IShareInfoInput>,
|
||||||
@ -22,7 +41,6 @@ export function useShareQuery(
|
|||||||
queryKey: ["shares", shareInput],
|
queryKey: ["shares", shareInput],
|
||||||
queryFn: () => getShareInfo(shareInput),
|
queryFn: () => getShareInfo(shareInput),
|
||||||
enabled: !!shareInput.pageId,
|
enabled: !!shareInput.pageId,
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return query;
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -3,11 +3,16 @@ import { IPage } from "@/features/page/types/page.types";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ICreateShare,
|
ICreateShare,
|
||||||
|
ISharedItem,
|
||||||
|
ISharedPageTree,
|
||||||
IShareInfoInput,
|
IShareInfoInput,
|
||||||
} from "@/features/share/types/share.types.ts";
|
} from "@/features/share/types/share.types.ts";
|
||||||
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
|
|
||||||
export async function getShares(data: ICreateShare): Promise<any> {
|
export async function getShares(
|
||||||
const req = await api.post<any>("/shares", data);
|
params?: QueryParams,
|
||||||
|
): Promise<IPagination<ISharedItem>> {
|
||||||
|
const req = await api.post("/shares", params);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,7 +22,7 @@ export async function createShare(data: ICreateShare): Promise<any> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getShareStatus(pageId: string): 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;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,10 +36,17 @@ export async function getShareInfo(
|
|||||||
export async function updateShare(
|
export async function updateShare(
|
||||||
data: Partial<IShareInfoInput>,
|
data: Partial<IShareInfoInput>,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const req = await api.post<IPage>("/shares/update", data);
|
const req = await api.post<any>("/shares/update", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteShare(shareId: string): Promise<void> {
|
export async function deleteShare(shareId: string): Promise<void> {
|
||||||
await api.post("/shares/delete", { shareId });
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
export interface ICreateShare {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
includeSubPages?: boolean;
|
includeSubPages?: boolean;
|
||||||
@ -5,4 +39,9 @@ export interface ICreateShare {
|
|||||||
|
|
||||||
export interface IShareInfoInput {
|
export interface IShareInfoInput {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ISharedPageTree {
|
||||||
|
share: IShare;
|
||||||
|
pageTree: Partial<IPage[]>;
|
||||||
|
}
|
||||||
|
|||||||
60
apps/client/src/features/share/utils.ts
Normal file
60
apps/client/src/features/share/utils.ts
Normal 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);
|
||||||
|
}
|
||||||
21
apps/client/src/pages/settings/shares/shares.tsx
Normal file
21
apps/client/src/pages/settings/shares/shares.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { useParams } from "react-router-dom";
|
|||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useShareQuery } from "@/features/share/queries/share-query.ts";
|
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 React from "react";
|
||||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
@ -36,17 +36,13 @@ export default function SingleSharedPage() {
|
|||||||
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<Container size={900} pt={50}>
|
<Container size={900}>
|
||||||
<ReadonlyPageEditor
|
<ReadonlyPageEditor
|
||||||
key={page.id}
|
key={page.id}
|
||||||
title={page.title}
|
title={page.title}
|
||||||
content={page.content}
|
content={page.content}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<Affix position={{ bottom: 20, right: 20 }}>
|
|
||||||
<Button variant="default">Powered by Docmost</Button>
|
|
||||||
</Affix>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -212,7 +212,7 @@ export class PageService {
|
|||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
const pageIds = await this.pageRepo
|
const pageIds = await this.pageRepo
|
||||||
.getPageAndDescendants(rootPage.id)
|
.getPageAndDescendants(rootPage.id, { includeContent: false })
|
||||||
.then((pages) => pages.map((page) => page.id));
|
.then((pages) => pages.map((page) => page.id));
|
||||||
// The first id is the root page id
|
// The first id is the root page id
|
||||||
if (pageIds.length > 1) {
|
if (pageIds.length > 1) {
|
||||||
|
|||||||
@ -140,4 +140,14 @@ export class ShareController {
|
|||||||
|
|
||||||
await this.shareRepo.deleteShare(share.id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,23 @@ export class ShareService {
|
|||||||
private readonly tokenService: TokenService,
|
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: {
|
async createShare(opts: {
|
||||||
authUserId: string;
|
authUserId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
|||||||
@ -26,7 +26,10 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
col.notNull().defaultTo(sql`now()`),
|
col.notNull().defaultTo(sql`now()`),
|
||||||
)
|
)
|
||||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||||
.addUniqueConstraint('shares_key_unique', ['key'])
|
.addUniqueConstraint('shares_key_workspace_id_unique', [
|
||||||
|
'key',
|
||||||
|
'workspace_id',
|
||||||
|
])
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -211,7 +211,10 @@ export class PageRepo {
|
|||||||
).as('contributors');
|
).as('contributors');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPageAndDescendants(parentPageId: string) {
|
async getPageAndDescendants(
|
||||||
|
parentPageId: string,
|
||||||
|
opts: { includeContent: boolean },
|
||||||
|
) {
|
||||||
return this.db
|
return this.db
|
||||||
.withRecursive('page_hierarchy', (db) =>
|
.withRecursive('page_hierarchy', (db) =>
|
||||||
db
|
db
|
||||||
@ -221,11 +224,12 @@ export class PageRepo {
|
|||||||
'slugId',
|
'slugId',
|
||||||
'title',
|
'title',
|
||||||
'icon',
|
'icon',
|
||||||
'content',
|
'position',
|
||||||
'parentPageId',
|
'parentPageId',
|
||||||
'spaceId',
|
'spaceId',
|
||||||
'workspaceId',
|
'workspaceId',
|
||||||
])
|
])
|
||||||
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.where('id', '=', parentPageId)
|
.where('id', '=', parentPageId)
|
||||||
.unionAll((exp) =>
|
.unionAll((exp) =>
|
||||||
exp
|
exp
|
||||||
@ -235,11 +239,12 @@ export class PageRepo {
|
|||||||
'p.slugId',
|
'p.slugId',
|
||||||
'p.title',
|
'p.title',
|
||||||
'p.icon',
|
'p.icon',
|
||||||
'p.content',
|
'p.position',
|
||||||
'p.parentPageId',
|
'p.parentPageId',
|
||||||
'p.spaceId',
|
'p.spaceId',
|
||||||
'p.workspaceId',
|
'p.workspaceId',
|
||||||
])
|
])
|
||||||
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -131,6 +131,7 @@ export class ShareRepo {
|
|||||||
const query = this.db
|
const query = this.db
|
||||||
.selectFrom('shares')
|
.selectFrom('shares')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
|
.select((eb) => this.withPage(eb))
|
||||||
.select((eb) => this.withSpace(eb))
|
.select((eb) => this.withSpace(eb))
|
||||||
.select((eb) => this.withCreator(eb))
|
.select((eb) => this.withCreator(eb))
|
||||||
.where('spaceId', 'in', userSpaceIds)
|
.where('spaceId', 'in', userSpaceIds)
|
||||||
@ -146,6 +147,15 @@ export class ShareRepo {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withPage(eb: ExpressionBuilder<DB, 'shares'>) {
|
||||||
|
return jsonObjectFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['pages.id', 'pages.title', 'pages.slugId', 'pages.icon'])
|
||||||
|
.whereRef('pages.id', '=', 'shares.pageId'),
|
||||||
|
).as('page');
|
||||||
|
}
|
||||||
|
|
||||||
withSpace(eb: ExpressionBuilder<DB, 'shares'>) {
|
withSpace(eb: ExpressionBuilder<DB, 'shares'>) {
|
||||||
return jsonObjectFrom(
|
return jsonObjectFrom(
|
||||||
eb
|
eb
|
||||||
|
|||||||
Submodule apps/server/src/ee updated: a04fcc224e...d3095f2d8b
@ -27,7 +27,10 @@ import { EditorState } from '@tiptap/pm/state';
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import slugify = require('@sindresorhus/slugify');
|
import slugify = require('@sindresorhus/slugify');
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
import { getAttachmentIds, getProsemirrorContent } from '../../common/helpers/prosemirror/utils';
|
import {
|
||||||
|
getAttachmentIds,
|
||||||
|
getProsemirrorContent,
|
||||||
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
@ -87,7 +90,9 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async exportPageWithChildren(pageId: string, format: string) {
|
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) {
|
if (!pages || pages.length === 0) {
|
||||||
throw new BadRequestException('No pages to export');
|
throw new BadRequestException('No pages to export');
|
||||||
|
|||||||
Reference in New Issue
Block a user