mirror of
https://github.com/docmost/docmost.git
synced 2025-11-17 05:41:16 +10:00
feat: implement Markdown and HTML page imports (#85)
* page import feature * make file interceptor common * replace @tiptap/html * update tiptap version * reduce table margin * update tiptap version * switch to upstream drag handle lib (fixes table dragging) * WIP * Page import module and other fixes * working page imports * extract page title from h1 heading * finalize page imports * cleanup unused imports * add menu arrow
This commit is contained in:
@ -60,7 +60,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
});
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
const [opened, { open: openExportModal, close: closeExportModal }] =
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
@ -156,7 +156,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
|
||||
<PageExportModal
|
||||
pageId={page.id}
|
||||
open={opened}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -36,29 +36,38 @@ export default function PageExportModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size="350"
|
||||
centered
|
||||
withCloseButton={false}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">Export format</Text>
|
||||
</div>
|
||||
<ExportFormatSelection onChange={handleChange} />
|
||||
</Group>
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size={500}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>Export page</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">Format</Text>
|
||||
</div>
|
||||
<ExportFormatSelection onChange={handleChange} />
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-start" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport}>Export</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
<Group justify="center" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport}>Export</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
148
apps/client/src/features/page/components/page-import-modal.tsx
Normal file
148
apps/client/src/features/page/components/page-import-modal.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconFileCode,
|
||||
IconMarkdown,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { importPage } from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { buildTree } from "@/features/page/tree/utils";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import React from "react";
|
||||
|
||||
interface PageImportModalProps {
|
||||
spaceId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PageImportModal({
|
||||
spaceId,
|
||||
open,
|
||||
onClose,
|
||||
}: PageImportModalProps) {
|
||||
return (
|
||||
<>
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size={600}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>Import pages</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<ImportFormatSelection spaceId={spaceId} onClose={onClose} />
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImportFormatSelection {
|
||||
spaceId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
|
||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||
if (!selectedFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
const alert = notifications.show({
|
||||
title: "Importing pages",
|
||||
message: "Page import is in progress. Please do not close this tab.",
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const pages: IPage[] = [];
|
||||
let pageCount = 0;
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
try {
|
||||
const page = await importPage(file, spaceId);
|
||||
pages.push(page);
|
||||
pageCount += 1;
|
||||
} catch (err) {
|
||||
console.log("Failed to import page", err);
|
||||
}
|
||||
}
|
||||
|
||||
const newTreeNodes = buildTree(pages);
|
||||
const fullTree = treeData.concat(newTreeNodes);
|
||||
|
||||
if (newTreeNodes?.length && fullTree?.length > 0) {
|
||||
setTreeData(fullTree);
|
||||
}
|
||||
|
||||
if (pageCount > 0) {
|
||||
notifications.update({
|
||||
id: alert,
|
||||
color: "teal",
|
||||
title: `Successfully imported ${pageCount} pages`,
|
||||
message: "Your import is complete.",
|
||||
icon: <IconCheck size={18} />,
|
||||
loading: false,
|
||||
autoClose: 5000,
|
||||
});
|
||||
} else {
|
||||
notifications.update({
|
||||
id: alert,
|
||||
color: "red",
|
||||
title: `Failed to import pages`,
|
||||
message: "Unable to import pages. Please try again.",
|
||||
icon: <IconX size={18} />,
|
||||
loading: false,
|
||||
autoClose: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={2}>
|
||||
<FileButton onChange={handleFileUpload} accept="text/markdown" multiple>
|
||||
{(props) => (
|
||||
<Button
|
||||
justify="start"
|
||||
variant="default"
|
||||
leftSection={<IconMarkdown size={18} />}
|
||||
{...props}
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton onChange={handleFileUpload} accept="text/html" multiple>
|
||||
{(props) => (
|
||||
<Button
|
||||
justify="start"
|
||||
variant="default"
|
||||
leftSection={<IconFileCode size={18} />}
|
||||
{...props}
|
||||
>
|
||||
HTML
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -67,6 +67,20 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
|
||||
saveAs(req.data, fileName);
|
||||
}
|
||||
|
||||
export async function importPage(file: File, spaceId: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("spaceId", spaceId);
|
||||
formData.append("file", file);
|
||||
|
||||
const req = await api.post<IPage>("/pages/import", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function uploadFile(file: File, pageId: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("pageId", pageId);
|
||||
|
||||
@ -447,9 +447,11 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={() =>
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) })
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
|
||||
Reference in New Issue
Block a user