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:
Philip Okugbe
2024-07-20 17:59:04 +01:00
committed by GitHub
parent 227ac30d5e
commit 937a07059a
35 changed files with 1163 additions and 1038 deletions

View File

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

View File

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

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

View File

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

View File

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