mirror of
https://github.com/docmost/docmost.git
synced 2025-11-18 09:31:10 +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 { 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 />} />}
|
||||
|
||||
@ -111,7 +111,7 @@ export default function GlobalAppShell({
|
||||
)}
|
||||
<AppShell.Main>
|
||||
{isSettingsRoute ? (
|
||||
<Container size={800}>{children}</Container>
|
||||
<Container size={850}>{children}</Container>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
@ -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 }),
|
||||
});
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>("");
|
||||
|
||||
@ -52,3 +52,8 @@
|
||||
) !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 = {
|
||||
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"
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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: [
|
||||
["♥", ""],
|
||||
|
||||
@ -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;
|
||||
|
||||
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 { 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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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[]>;
|
||||
}
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -26,7 +26,10 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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<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'>) {
|
||||
return jsonObjectFrom(
|
||||
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
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user