mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-12 15:52:32 +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:
@ -42,10 +42,12 @@
|
||||
"react-router-dom": "^6.24.0",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.10",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/eslint-plugin-query": "^5.47.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "20.14.9",
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
import {
|
||||
Editor,
|
||||
NodeViewContent,
|
||||
NodeViewProps,
|
||||
NodeViewWrapper,
|
||||
} from "@tiptap/react";
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import {
|
||||
IconAlertTriangleFilled,
|
||||
IconCircleCheckFilled,
|
||||
|
||||
@ -1,371 +0,0 @@
|
||||
// MIT - source: https://github.com/NiclasDev63/tiptap-extension-global-drag-handle
|
||||
import { Extension } from "@tiptap/core";
|
||||
import {
|
||||
NodeSelection,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
TextSelection,
|
||||
} from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
||||
|
||||
// @ts-ignore
|
||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
||||
|
||||
export interface GlobalDragHandleOptions {
|
||||
/**
|
||||
* The width of the drag handle
|
||||
*/
|
||||
dragHandleWidth: number;
|
||||
|
||||
/**
|
||||
* The treshold for scrolling
|
||||
*/
|
||||
scrollTreshold: number;
|
||||
|
||||
/*
|
||||
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
||||
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
||||
*/
|
||||
dragHandleSelector?: string;
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
const modal = node.closest('[role="dialog"]');
|
||||
|
||||
if (modal && window.getComputedStyle(modal).transform !== "none") {
|
||||
const modalRect = modal.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top - modalRect.top,
|
||||
left: data.left - modalRect.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: data.top,
|
||||
left: data.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
||||
return document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find(
|
||||
(elem: Element) =>
|
||||
elem.parentElement?.matches?.(".ProseMirror") ||
|
||||
elem.matches(
|
||||
[
|
||||
"li",
|
||||
"p:not(:first-child)",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"h1, h2, h3, h4, h5, h6",
|
||||
".tableWrapper",
|
||||
].join(", "),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function nodePosAtDOM(
|
||||
node: Element,
|
||||
view: EditorView,
|
||||
options: GlobalDragHandleOptions,
|
||||
) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||
top: boundingRect.top + 1,
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function calcNodePos(pos: number, view: EditorView) {
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
||||
return pos;
|
||||
}
|
||||
|
||||
export function DragHandlePlugin(
|
||||
options: GlobalDragHandleOptions & { pluginKey: string },
|
||||
) {
|
||||
let listType = "";
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
||||
|
||||
const { from, to } = view.state.selection;
|
||||
const diff = from - to;
|
||||
|
||||
const fromSelectionPos = calcNodePos(from, view);
|
||||
let differentNodeSelected = false;
|
||||
|
||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||
|
||||
// Check if nodePos points to the top level node
|
||||
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
||||
else {
|
||||
const nodeSelection = NodeSelection.create(
|
||||
view.state.doc,
|
||||
nodePos.before(),
|
||||
);
|
||||
// Check if the node where the drag event started is part of the current selection
|
||||
differentNodeSelected = !(
|
||||
draggedNodePos + 1 >= nodeSelection.$from.pos &&
|
||||
draggedNodePos <= nodeSelection.$to.pos
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!differentNodeSelected &&
|
||||
diff !== 0 &&
|
||||
!(view.state.selection instanceof NodeSelection)
|
||||
) {
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||
const multiNodeSelection = TextSelection.create(
|
||||
view.state.doc,
|
||||
draggedNodePos,
|
||||
endSelection.$to.pos,
|
||||
);
|
||||
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
|
||||
} else {
|
||||
const nodeSelection = NodeSelection.create(
|
||||
view.state.doc,
|
||||
draggedNodePos,
|
||||
);
|
||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
||||
}
|
||||
|
||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem"
|
||||
) {
|
||||
listType = node.parentElement!.tagName;
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = __serializeForClipboard(view, slice);
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData("text/html", dom.innerHTML);
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
event.dataTransfer.effectAllowed = "copyMove";
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
|
||||
function hideDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.add("hide");
|
||||
}
|
||||
}
|
||||
|
||||
function showDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.remove("hide");
|
||||
}
|
||||
}
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey(options.pluginKey),
|
||||
view: (view) => {
|
||||
const handleBySelector = options.dragHandleSelector
|
||||
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
||||
: null;
|
||||
dragHandleElement = handleBySelector ?? document.createElement("div");
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = "";
|
||||
dragHandleElement.classList.add("drag-handle");
|
||||
|
||||
function onDragHandleDragStart(e: DragEvent) {
|
||||
handleDragStart(e, view);
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
|
||||
|
||||
function onDragHandleDrag(e: DragEvent) {
|
||||
hideDragHandle();
|
||||
let scrollY = window.scrollY;
|
||||
if (e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener("drag", onDragHandleDrag);
|
||||
|
||||
hideDragHandle();
|
||||
|
||||
if (!handleBySelector) {
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
}
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
if (!handleBySelector) {
|
||||
dragHandleElement?.remove?.();
|
||||
}
|
||||
dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
|
||||
dragHandleElement?.removeEventListener(
|
||||
"dragstart",
|
||||
onDragHandleDragStart,
|
||||
);
|
||||
dragHandleElement = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const notDragging = node?.closest(".not-draggable");
|
||||
|
||||
//const isNodeInTable = node?.closest("td") || node?.closest("th");
|
||||
|
||||
if (
|
||||
!(node instanceof Element) ||
|
||||
node.matches("ul, ol") ||
|
||||
notDragging ||
|
||||
node.matches(".tableWrapper")
|
||||
) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
}
|
||||
|
||||
const compStyle = window.getComputedStyle(node);
|
||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
||||
const lineHeight = isNaN(parsedLineHeight)
|
||||
? parseInt(compStyle.fontSize) * 1.2
|
||||
: parsedLineHeight;
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
|
||||
rect.top += (lineHeight - 24) / 2;
|
||||
rect.top += paddingTop;
|
||||
// Li markers
|
||||
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
||||
rect.left -= options.dragHandleWidth;
|
||||
}
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
// dragging class is used for CSS
|
||||
dragstart: (view) => {
|
||||
view.dom.classList.add("dragging");
|
||||
},
|
||||
drop: (view, event) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!dropPos) return;
|
||||
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
if (!droppedNode) return;
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
|
||||
const isDroppedInsideList =
|
||||
resolvedPos.parent.type.name === "listItem";
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem" &&
|
||||
!isDroppedInsideList &&
|
||||
listType == "OL"
|
||||
) {
|
||||
const text = droppedNode.textContent;
|
||||
if (!text) return;
|
||||
const paragraph = view.state.schema.nodes.paragraph?.createAndFill(
|
||||
{},
|
||||
view.state.schema.text(text),
|
||||
);
|
||||
const listItem = view.state.schema.nodes.listItem?.createAndFill(
|
||||
{},
|
||||
paragraph,
|
||||
);
|
||||
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(
|
||||
null,
|
||||
listItem,
|
||||
);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const GlobalDragHandle = Extension.create({
|
||||
name: "globalDragHandle",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
dragHandleWidth: 20,
|
||||
scrollTreshold: 100,
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandlePlugin({
|
||||
pluginKey: "globalDragHandle",
|
||||
dragHandleWidth: this.options.dragHandleWidth,
|
||||
scrollTreshold: this.options.scrollTreshold,
|
||||
dragHandleSelector: this.options.dragHandleSelector,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default GlobalDragHandle;
|
||||
@ -41,7 +41,7 @@ import {
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
||||
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
||||
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||
import { Youtube } from "@tiptap/extension-youtube";
|
||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
.tableWrapper {
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 3rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow-x: auto;
|
||||
& table {
|
||||
overflow-x: hidden;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button, Divider, Group, Modal, ScrollArea } from "@mantine/core";
|
||||
import { Button, Divider, Group, Modal } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import React, { useState } from "react";
|
||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||
|
||||
@ -2,7 +2,6 @@ import {
|
||||
usePageHistoryListQuery,
|
||||
usePageHistoryQuery,
|
||||
} from "@/features/page-history/queries/page-history-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import HistoryItem from "@/features/page-history/components/history-item";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -2,7 +2,7 @@ import { Group, Center, Text } from "@mantine/core";
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconFileDescription, IconSearch } from "@tabler/icons-react";
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Modal, Tabs, rem, Group, Divider, ScrollArea } from "@mantine/core";
|
||||
import { Modal, Tabs, rem, Group, ScrollArea } from "@mantine/core";
|
||||
import SpaceMembersList from "@/features/space/components/space-members.tsx";
|
||||
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { UnstyledButton, Group, Avatar, Text, rem } from "@mantine/core";
|
||||
import { UnstyledButton, Group, Text } from "@mantine/core";
|
||||
import classes from "./space-name.module.css";
|
||||
|
||||
interface SpaceNameProps {
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
rem,
|
||||
Menu,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { spotlight } from "@mantine/spotlight";
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconDots,
|
||||
IconHome,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@ -32,6 +34,7 @@ import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
|
||||
|
||||
export function SpaceSidebar() {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
@ -140,18 +143,20 @@ export function SpaceSidebar() {
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
) && (
|
||||
<Tooltip label="Create page" withArrow position="right">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
>
|
||||
<IconPlus
|
||||
style={{ width: rem(12), height: rem(12) }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Group gap="xs">
|
||||
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
|
||||
|
||||
<Tooltip label="Create page" withArrow position="right">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
aria-label="Create page"
|
||||
>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@ -177,3 +182,54 @@ export function SpaceSidebar() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpaceMenuProps {
|
||||
spaceId: string;
|
||||
onSpaceSettings: () => void;
|
||||
}
|
||||
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu width={200} shadow="md" withArrow>
|
||||
<Menu.Target>
|
||||
<Tooltip
|
||||
label="Import pages & space settings"
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<ActionIcon variant="default" size={18} aria-label="Space menu">
|
||||
<IconDots />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={openImportModal}
|
||||
leftSection={<IconArrowDown size={16} />}
|
||||
>
|
||||
Import pages
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
onClick={onSpaceSettings}
|
||||
leftSection={<IconSettings size={16} />}
|
||||
>
|
||||
Space settings
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<PageImportModal
|
||||
spaceId={spaceId}
|
||||
open={importOpened}
|
||||
onClose={closeImportModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
|
||||
import { useParams } from "react-router-dom";
|
||||
import React from "react";
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Group, Table, Avatar, Text, Badge, Alert } from "@mantine/core";
|
||||
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
||||
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import React from "react";
|
||||
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Group, Table, Avatar, Text, Badge } from "@mantine/core";
|
||||
import { Group, Table, Text, Badge } from "@mantine/core";
|
||||
import {
|
||||
useChangeMemberRoleMutation,
|
||||
useWorkspaceMembersQuery,
|
||||
|
||||
Reference in New Issue
Block a user