mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 04:32:41 +10:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48e76aa9f4 | |||
| 2bd6422a35 | |||
| 407a1aff3b | |||
| b4bc184cb3 | |||
| 109dbdbe02 | |||
| 2df7de5828 | |||
| 373fc86e47 | |||
| 5052a9ea40 | |||
| cd47c79d86 | |||
| 78746938b7 | |||
| 4d2936627c | |||
| d2ecd28047 | |||
| bb92ca75e9 | |||
| 8f3e2ff663 | |||
| 89f6311e46 | |||
| e5a97d2a26 | |||
| 7f0fd45f3a | |||
| 078959dfa0 | |||
| 937a07059a | |||
| 227ac30d5e | |||
| a2ae341934 | |||
| 3c70e40d16 | |||
| 14197d7365 | |||
| f388540293 | |||
| b43de81013 | |||
| 6659adc7fe | |||
| 24adff9679 | |||
| e960b8c1a9 | |||
| 1958067110 | |||
| 3e519ebcd8 | |||
| 07cd650205 | |||
| 949d782a28 | |||
| 295d4325bf | |||
| 40a40bb3c7 | |||
| bc1579b022 | |||
| 85b3073681 | |||
| 4af3a54649 | |||
| c4c169b17a | |||
| a0536d852f | |||
| f12f93b373 | |||
| 35dcd5f254 | |||
| 9496ec9b57 | |||
| 5ace7616d0 | |||
| ce6a05ab66 | |||
| 66773dfaca |
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.3",
|
"version": "0.2.8",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@ -26,6 +26,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"jotai": "^2.8.3",
|
"jotai": "^2.8.3",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
@ -41,10 +42,12 @@
|
|||||||
"react-router-dom": "^6.24.0",
|
"react-router-dom": "^6.24.0",
|
||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
|
"tiptap-extension-global-drag-handle": "^0.1.10",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/eslint-plugin-query": "^5.47.0",
|
"@tanstack/eslint-plugin-query": "^5.47.0",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/katex": "^0.16.7",
|
"@types/katex": "^0.16.7",
|
||||||
"@types/node": "20.14.9",
|
"@types/node": "20.14.9",
|
||||||
|
|||||||
@ -23,7 +23,7 @@ const RoleButton = forwardRef<HTMLButtonElement, RoleButtonProps>(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
interface SpaceRoleMenuProps {
|
interface RoleMenuProps {
|
||||||
roles: IRoleData[];
|
roles: IRoleData[];
|
||||||
roleName: string;
|
roleName: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
@ -35,7 +35,7 @@ export default function RoleSelectMenu({
|
|||||||
roleName,
|
roleName,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: SpaceRoleMenuProps) {
|
}: RoleMenuProps) {
|
||||||
return (
|
return (
|
||||||
<Menu withArrow>
|
<Menu withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { createJSONStorage, atomWithStorage } from "jotai/utils";
|
import { createJSONStorage, atomWithStorage } from "jotai/utils";
|
||||||
import { ITokens } from '../types/auth.types';
|
import { ITokens } from "../types/auth.types";
|
||||||
|
|
||||||
|
|
||||||
const cookieStorage = createJSONStorage<ITokens>(() => {
|
const cookieStorage = createJSONStorage<ITokens>(() => {
|
||||||
return {
|
return {
|
||||||
getItem: () => Cookies.get("authTokens"),
|
getItem: () => Cookies.get("authTokens"),
|
||||||
setItem: (key, value) => Cookies.set(key, value),
|
setItem: (key, value) => Cookies.set(key, value, { expires: 30 }),
|
||||||
removeItem: (key) => Cookies.remove(key),
|
removeItem: (key) => Cookies.remove(key),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const authTokensAtom = atomWithStorage<ITokens | null>("authTokens", null, cookieStorage);
|
export const authTokensAtom = atomWithStorage<ITokens | null>(
|
||||||
|
"authTokens",
|
||||||
|
null,
|
||||||
|
cookieStorage,
|
||||||
|
);
|
||||||
|
|||||||
@ -4,11 +4,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import { ILogin, ISetupWorkspace } from "@/features/auth/types/auth.types";
|
||||||
ILogin,
|
|
||||||
IRegister,
|
|
||||||
ISetupWorkspace,
|
|
||||||
} from "@/features/auth/types/auth.types";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
|
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
|
||||||
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
|
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
|
||||||
@ -49,7 +45,6 @@ export default function useAuth() {
|
|||||||
const res = await acceptInvitation(data);
|
const res = await acceptInvitation(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
console.log(res);
|
|
||||||
setAuthToken(res.tokens);
|
setAuthToken(res.tokens);
|
||||||
|
|
||||||
navigate(APP_ROUTE.HOME);
|
navigate(APP_ROUTE.HOME);
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
import {
|
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
Editor,
|
|
||||||
NodeViewContent,
|
|
||||||
NodeViewProps,
|
|
||||||
NodeViewWrapper,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import {
|
import {
|
||||||
IconAlertTriangleFilled,
|
IconAlertTriangleFilled,
|
||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
|
|||||||
@ -184,7 +184,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.deleteRange(range)
|
.deleteRange(range)
|
||||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: false })
|
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||||
.run(),
|
.run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -4,13 +4,14 @@ import { TextAlign } from "@tiptap/extension-text-align";
|
|||||||
import { TaskList } from "@tiptap/extension-task-list";
|
import { TaskList } from "@tiptap/extension-task-list";
|
||||||
import { TaskItem } from "@tiptap/extension-task-item";
|
import { TaskItem } from "@tiptap/extension-task-item";
|
||||||
import { Underline } from "@tiptap/extension-underline";
|
import { Underline } from "@tiptap/extension-underline";
|
||||||
import { Link } from "@tiptap/extension-link";
|
|
||||||
import { Superscript } from "@tiptap/extension-superscript";
|
import { Superscript } from "@tiptap/extension-superscript";
|
||||||
import SubScript from "@tiptap/extension-subscript";
|
import SubScript from "@tiptap/extension-subscript";
|
||||||
import { Highlight } from "@tiptap/extension-highlight";
|
import { Highlight } from "@tiptap/extension-highlight";
|
||||||
import { Typography } from "@tiptap/extension-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
|
import Table from "@tiptap/extension-table";
|
||||||
|
import TableHeader from "@tiptap/extension-table-header";
|
||||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||||
@ -23,8 +24,6 @@ import {
|
|||||||
DetailsSummary,
|
DetailsSummary,
|
||||||
MathBlock,
|
MathBlock,
|
||||||
MathInline,
|
MathInline,
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
@ -41,7 +40,7 @@ import {
|
|||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||||
import MathBlockView from "@/features/editor/components/math/math-block.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 { Youtube } from "@tiptap/extension-youtube";
|
||||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
@ -66,7 +65,9 @@ export const mainExtensions = [
|
|||||||
if (node.type.name === "detailsSummary") {
|
if (node.type.name === "detailsSummary") {
|
||||||
return "Toggle title";
|
return "Toggle title";
|
||||||
}
|
}
|
||||||
return 'Write anything. Enter "/" for commands';
|
if (node.type.name === "paragraph") {
|
||||||
|
return 'Write anything. Enter "/" for commands';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
includeChildren: true,
|
includeChildren: true,
|
||||||
}),
|
}),
|
||||||
@ -95,10 +96,16 @@ export const mainExtensions = [
|
|||||||
class: "comment-mark",
|
class: "comment-mark",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Table,
|
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
lastColumnResizable: false,
|
||||||
|
allowTableNodeSelection: true,
|
||||||
|
}),
|
||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
|
|
||||||
MathInline.configure({
|
MathInline.configure({
|
||||||
view: MathInlineView,
|
view: MathInlineView,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { TitleEditor } from "@/features/editor/title-editor";
|
import { TitleEditor } from "@/features/editor/title-editor";
|
||||||
import PageEditor from "@/features/editor/page-editor";
|
import PageEditor from "@/features/editor/page-editor";
|
||||||
import { Container } from "@mantine/core";
|
import { Container } from "@mantine/core";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai";
|
||||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
|
|||||||
@ -163,3 +163,4 @@
|
|||||||
.actionIconGroup {
|
.actionIconGroup {
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,5 +2,10 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
margin: 64px auto;
|
margin: 64px auto;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,5 +8,7 @@
|
|||||||
@import "./youtube.css";
|
@import "./youtube.css";
|
||||||
@import "./media.css";
|
@import "./media.css";
|
||||||
@import "./code.css";
|
@import "./code.css";
|
||||||
|
@import "./print.css";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,10 @@
|
|||||||
color: #adb5bd;
|
color: #adb5bd;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror .is-empty::before {
|
.ProseMirror .is-empty::before {
|
||||||
@ -12,9 +16,17 @@
|
|||||||
color: #adb5bd;
|
color: #adb5bd;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror table .is-editor-empty:first-child::before,
|
.ProseMirror table .is-editor-empty:first-child::before,
|
||||||
.ProseMirror table .is-empty::before {
|
.ProseMirror table .is-empty::before {
|
||||||
content: '';
|
content: '';
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
apps/client/src/features/editor/styles/print.css
Normal file
11
apps/client/src/features/editor/styles/print.css
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
@media print {
|
||||||
|
.mantine-AppShell-header,
|
||||||
|
.mantine-AppShell-navbar,
|
||||||
|
.mantine-AppShell-aside{
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mantine-AppShell-main {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
.tableWrapper {
|
.tableWrapper {
|
||||||
margin-top: 3rem;
|
margin-top: 1rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 1rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
& table {
|
& table {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
@ -17,5 +17,9 @@
|
|||||||
&.ProseMirror-selectednode {
|
&.ProseMirror-selectednode {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { useDisclosure } from "@mantine/hooks";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import {
|
|||||||
usePageHistoryListQuery,
|
usePageHistoryListQuery,
|
||||||
usePageHistoryQuery,
|
usePageHistoryQuery,
|
||||||
} from "@/features/page-history/queries/page-history-query";
|
} from "@/features/page-history/queries/page-history-query";
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import HistoryItem from "@/features/page-history/components/history-item";
|
import HistoryItem from "@/features/page-history/components/history-item";
|
||||||
import {
|
import {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
|
|||||||
@ -2,16 +2,18 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
|
|||||||
import {
|
import {
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
IconDots,
|
IconDots,
|
||||||
|
IconDownload,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
|
IconPrinter,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
import { useClipboard } from "@mantine/hooks";
|
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@ -21,6 +23,7 @@ import { extractPageSlugId } from "@/lib";
|
|||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||||
|
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@ -57,6 +60,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
});
|
});
|
||||||
const { openDeleteModal } = useDeletePageModal();
|
const { openDeleteModal } = useDeletePageModal();
|
||||||
const [tree] = useAtom(treeApiAtom);
|
const [tree] = useAtom(treeApiAtom);
|
||||||
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
@ -66,6 +71,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
notifications.show({ message: "Link copied" });
|
notifications.show({ message: "Link copied" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
const openHistoryModal = () => {
|
const openHistoryModal = () => {
|
||||||
setHistoryModalOpen(true);
|
setHistoryModalOpen(true);
|
||||||
};
|
};
|
||||||
@ -75,55 +86,79 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<>
|
||||||
shadow="xl"
|
<Menu
|
||||||
position="bottom-end"
|
shadow="xl"
|
||||||
offset={20}
|
position="bottom-end"
|
||||||
width={200}
|
offset={20}
|
||||||
withArrow
|
width={200}
|
||||||
arrowPosition="center"
|
withArrow
|
||||||
>
|
arrowPosition="center"
|
||||||
<Menu.Target>
|
>
|
||||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
<Menu.Target>
|
||||||
<IconDots size={20} stroke={2} />
|
<ActionIcon variant="default" style={{ border: "none" }}>
|
||||||
</ActionIcon>
|
<IconDots size={20} />
|
||||||
</Menu.Target>
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconLink size={16} stroke={2} />}
|
leftSection={<IconLink size={16} />}
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
>
|
>
|
||||||
Copy link
|
Copy link
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} stroke={2} />}>
|
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<PageWidthToggle label="Full width" />
|
<PageWidthToggle label="Full width" />
|
||||||
</Group>
|
</Group>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconHistory size={16} stroke={2} />}
|
leftSection={<IconHistory size={16} />}
|
||||||
onClick={openHistoryModal}
|
onClick={openHistoryModal}
|
||||||
>
|
>
|
||||||
Page history
|
Page history
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
{!readOnly && (
|
<Menu.Divider />
|
||||||
<>
|
|
||||||
<Menu.Divider />
|
<Menu.Item
|
||||||
<Menu.Item
|
leftSection={<IconDownload size={16} />}
|
||||||
color={"red"}
|
onClick={openExportModal}
|
||||||
leftSection={<IconTrash size={16} stroke={2} />}
|
>
|
||||||
onClick={handleDeletePage}
|
Export
|
||||||
>
|
</Menu.Item>
|
||||||
Delete
|
|
||||||
</Menu.Item>
|
<Menu.Item
|
||||||
</>
|
leftSection={<IconPrinter size={16} />}
|
||||||
)}
|
onClick={handlePrint}
|
||||||
</Menu.Dropdown>
|
>
|
||||||
</Menu>
|
Print PDF
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item
|
||||||
|
color={"red"}
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
onClick={handleDeletePage}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<PageExportModal
|
||||||
|
pageId={page.id}
|
||||||
|
open={exportOpened}
|
||||||
|
onClose={closeExportModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,4 +8,8 @@
|
|||||||
top: var(--app-shell-header-offset, 0rem);
|
top: var(--app-shell-header-offset, 0rem);
|
||||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,94 @@
|
|||||||
|
import { Modal, Button, Group, Text, Select } from "@mantine/core";
|
||||||
|
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||||
|
import { useState } from "react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
|
interface PageExportModalProps {
|
||||||
|
pageId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageExportModal({
|
||||||
|
pageId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: PageExportModalProps) {
|
||||||
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
await exportPage({ pageId: pageId, format });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: "Export failed:" + err.response?.data.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
console.error("export error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (format: ExportFormat) => {
|
||||||
|
setFormat(format);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 format={format} onChange={handleChange} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="center" mt="md">
|
||||||
|
<Button onClick={onClose} variant="default">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport}>Export</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportFormatSelection {
|
||||||
|
format: ExportFormat;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
data={[
|
||||||
|
{ value: "markdown", label: "Markdown" },
|
||||||
|
{ value: "html", label: "HTML" },
|
||||||
|
]}
|
||||||
|
defaultValue={format}
|
||||||
|
onChange={onChange}
|
||||||
|
styles={{ wrapper: { maxWidth: 120 } }}
|
||||||
|
comboboxProps={{ width: "120" }}
|
||||||
|
allowDeselect={false}
|
||||||
|
withCheckIcon={false}
|
||||||
|
aria-label="Select export format"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
apps/client/src/features/page/components/page-import-modal.tsx
Normal file
150
apps/client/src/features/page/components/page-import-modal.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pages?.length > 0 && pageCount > 0) {
|
||||||
|
const newTreeNodes = buildTree(pages);
|
||||||
|
const fullTree = treeData.concat(newTreeNodes);
|
||||||
|
|
||||||
|
if (newTreeNodes?.length && fullTree?.length > 0) {
|
||||||
|
setTreeData(fullTree);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageCountText = pageCount === 1 ? "1 page" : `${pageCount} pages`;
|
||||||
|
|
||||||
|
notifications.update({
|
||||||
|
id: alert,
|
||||||
|
color: "teal",
|
||||||
|
title: `Successfully imported ${pageCountText}`,
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1,13 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import {
|
import {
|
||||||
|
IExportPageParams,
|
||||||
IMovePage,
|
IMovePage,
|
||||||
IPage,
|
IPage,
|
||||||
IPageInput,
|
IPageInput,
|
||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
} from "@/features/page/types/page.types";
|
} from "@/features/page/types/page.types";
|
||||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||||
|
import { saveAs } from "file-saver";
|
||||||
|
|
||||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/create", data);
|
const req = await api.post<IPage>("/pages/create", data);
|
||||||
@ -53,18 +55,42 @@ export async function getRecentChanges(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportPage(data: IExportPageParams): Promise<void> {
|
||||||
|
const req = await api.post("/pages/export", data, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = req?.headers["content-disposition"]
|
||||||
|
.split("filename=")[1]
|
||||||
|
.replace(/"/g, "");
|
||||||
|
|
||||||
|
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) {
|
export async function uploadFile(file: File, pageId: string) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("pageId", pageId);
|
formData.append("pageId", pageId);
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|
||||||
// should be file endpoint
|
|
||||||
const req = await api.post<IAttachment>("/files/upload", formData, {
|
const req = await api.post<IAttachment>("/files/upload", formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// console.log("req", req);
|
|
||||||
|
|
||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -447,9 +447,11 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
leftSection={
|
leftSection={
|
||||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={(e) => {
|
||||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) })
|
e.preventDefault();
|
||||||
}
|
e.stopPropagation();
|
||||||
|
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@ -44,3 +44,13 @@ export interface IPageInput {
|
|||||||
coverPhoto: string;
|
coverPhoto: string;
|
||||||
position: string;
|
position: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IExportPageParams {
|
||||||
|
pageId: string;
|
||||||
|
format: ExportFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ExportFormat {
|
||||||
|
HTML = "html",
|
||||||
|
Markdown = "markdown",
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Group, Center, Text } from "@mantine/core";
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
import { IconFileDescription, IconSearch } from "@tabler/icons-react";
|
import { IconFileDescription, IconSearch } from "@tabler/icons-react";
|
||||||
import React, { useState } from "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 { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
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 SpaceMembersList from "@/features/space/components/space-members.tsx";
|
||||||
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
||||||
import React, { useMemo } from "react";
|
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";
|
import classes from "./space-name.module.css";
|
||||||
|
|
||||||
interface SpaceNameProps {
|
interface SpaceNameProps {
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Group,
|
Group,
|
||||||
rem,
|
Menu,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { spotlight } from "@mantine/spotlight";
|
import { spotlight } from "@mantine/spotlight";
|
||||||
import {
|
import {
|
||||||
|
IconArrowDown,
|
||||||
|
IconDots,
|
||||||
IconHome,
|
IconHome,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
@ -32,6 +34,7 @@ import {
|
|||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
|
||||||
|
|
||||||
export function SpaceSidebar() {
|
export function SpaceSidebar() {
|
||||||
const [tree] = useAtom(treeApiAtom);
|
const [tree] = useAtom(treeApiAtom);
|
||||||
@ -140,18 +143,20 @@ export function SpaceSidebar() {
|
|||||||
SpaceCaslAction.Manage,
|
SpaceCaslAction.Manage,
|
||||||
SpaceCaslSubject.Page,
|
SpaceCaslSubject.Page,
|
||||||
) && (
|
) && (
|
||||||
<Tooltip label="Create page" withArrow position="right">
|
<Group gap="xs">
|
||||||
<ActionIcon
|
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
|
||||||
variant="default"
|
|
||||||
size={18}
|
<Tooltip label="Create page" withArrow position="right">
|
||||||
onClick={handleCreatePage}
|
<ActionIcon
|
||||||
>
|
variant="default"
|
||||||
<IconPlus
|
size={18}
|
||||||
style={{ width: rem(12), height: rem(12) }}
|
onClick={handleCreatePage}
|
||||||
stroke={1.5}
|
aria-label="Create page"
|
||||||
/>
|
>
|
||||||
</ActionIcon>
|
<IconPlus />
|
||||||
</Tooltip>
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
</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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import React, { useState } from "react";
|
|||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { formatMemberCount } from "@/lib";
|
||||||
|
|
||||||
export default function SpaceList() {
|
export default function SpaceList() {
|
||||||
const { data, isLoading } = useGetSpacesQuery();
|
const { data, isLoading } = useGetSpacesQuery();
|
||||||
@ -50,7 +51,7 @@ export default function SpaceList() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>{space.memberCount} members</Table.Td>
|
<Table.Td>{formatMemberCount(space.memberCount)}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
|
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { IconDots } from "@tabler/icons-react";
|
import { IconDots } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
|
|||||||
@ -95,9 +95,11 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
|
|||||||
{...form.getInputProps("newPassword")}
|
{...form.getInputProps("newPassword")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
<Group justify="flex-end" mt="md">
|
||||||
Change password
|
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
||||||
</Button>
|
Change password
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
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 {
|
import {
|
||||||
useChangeMemberRoleMutation,
|
useChangeMemberRoleMutation,
|
||||||
useWorkspaceMembersQuery,
|
useWorkspaceMembersQuery,
|
||||||
@ -11,11 +11,14 @@ import {
|
|||||||
userRoleData,
|
userRoleData,
|
||||||
} from "@/features/workspace/types/user-role-data.ts";
|
} from "@/features/workspace/types/user-role-data.ts";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { UserRole } from "@/lib/types.ts";
|
||||||
|
|
||||||
export default function WorkspaceMembersTable() {
|
export default function WorkspaceMembersTable() {
|
||||||
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
|
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
|
||||||
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin, isOwner } = useUserRole();
|
||||||
|
|
||||||
|
const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER);
|
||||||
|
|
||||||
const handleRoleChange = async (
|
const handleRoleChange = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
@ -69,7 +72,7 @@ export default function WorkspaceMembersTable() {
|
|||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<RoleSelectMenu
|
<RoleSelectMenu
|
||||||
roles={userRoleData}
|
roles={assignableUserRoles}
|
||||||
roleName={getUserRoleLabel(user.role)}
|
roleName={getUserRoleLabel(user.role)}
|
||||||
onChange={(newRole) =>
|
onChange={(newRole) =>
|
||||||
handleRoleChange(user.id, user.role, newRole)
|
handleRoleChange(user.id, user.role, newRole)
|
||||||
|
|||||||
@ -53,9 +53,10 @@ export function useChangeMemberRoleMutation() {
|
|||||||
return useMutation<any, Error, any>({
|
return useMutation<any, Error, any>({
|
||||||
mutationFn: (data) => changeMemberRole(data),
|
mutationFn: (data) => changeMemberRole(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
|
// TODO: change in cache instead
|
||||||
notifications.show({ message: "Member role updated successfully" });
|
notifications.show({ message: "Member role updated successfully" });
|
||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
queryKey: ["workspaceMembers", variables.spaceId],
|
queryKey: ["workspaceMembers"],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import Routes from "@/lib/app-route.ts";
|
import Routes from "@/lib/app-route.ts";
|
||||||
import { getBackendUrl } from "@/lib/config.ts";
|
|
||||||
|
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
baseURL: getBackendUrl(),
|
baseURL: "/api",
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -32,6 +31,11 @@ api.interceptors.request.use(
|
|||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
|
// we need the response headers
|
||||||
|
if (response.request.responseURL.includes("/api/pages/export")) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@ -22,6 +22,10 @@ export interface IRoleData {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
export type IPaginationMeta = {
|
export type IPaginationMeta = {
|
||||||
limit: number;
|
limit: number;
|
||||||
page: number;
|
page: number;
|
||||||
|
|||||||
@ -19,5 +19,13 @@ export default defineConfig(({ mode }) => {
|
|||||||
"@": "/src",
|
"@": "/src",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: APP_URL,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.2.3",
|
"version": "0.2.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@ -44,6 +44,7 @@
|
|||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-fastify": "^10.3.9",
|
"@nestjs/platform-fastify": "^10.3.9",
|
||||||
"@nestjs/platform-socket.io": "^10.3.9",
|
"@nestjs/platform-socket.io": "^10.3.9",
|
||||||
|
"@nestjs/terminus": "^10.2.3",
|
||||||
"@nestjs/websockets": "^10.3.9",
|
"@nestjs/websockets": "^10.3.9",
|
||||||
"@react-email/components": "0.0.19",
|
"@react-email/components": "0.0.19",
|
||||||
"@react-email/render": "^0.0.15",
|
"@react-email/render": "^0.0.15",
|
||||||
@ -57,8 +58,10 @@
|
|||||||
"fastify": "^4.28.0",
|
"fastify": "^4.28.0",
|
||||||
"fix-esm": "^1.0.1",
|
"fix-esm": "^1.0.1",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
|
"happy-dom": "^14.12.3",
|
||||||
"kysely": "^0.27.3",
|
"kysely": "^0.27.3",
|
||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
|
"marked": "^13.0.2",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nestjs-kysely": "^1.0.0",
|
"nestjs-kysely": "^1.0.0",
|
||||||
|
|||||||
@ -11,6 +11,9 @@ import { MailModule } from './integrations/mail/mail.module';
|
|||||||
import { QueueModule } from './integrations/queue/queue.module';
|
import { QueueModule } from './integrations/queue/queue.module';
|
||||||
import { StaticModule } from './integrations/static/static.module';
|
import { StaticModule } from './integrations/static/static.module';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { HealthModule } from './integrations/health/health.module';
|
||||||
|
import { ExportModule } from './integrations/export/export.module';
|
||||||
|
import { ImportModule } from './integrations/import/import.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -21,6 +24,9 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
|||||||
WsModule,
|
WsModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
StaticModule,
|
StaticModule,
|
||||||
|
HealthModule,
|
||||||
|
ImportModule,
|
||||||
|
ExportModule,
|
||||||
StorageModule.forRootAsync({
|
StorageModule.forRootAsync({
|
||||||
imports: [EnvironmentModule],
|
imports: [EnvironmentModule],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import { Typography } from '@tiptap/extension-typography';
|
|||||||
import { TextStyle } from '@tiptap/extension-text-style';
|
import { TextStyle } from '@tiptap/extension-text-style';
|
||||||
import { Color } from '@tiptap/extension-color';
|
import { Color } from '@tiptap/extension-color';
|
||||||
import { Youtube } from '@tiptap/extension-youtube';
|
import { Youtube } from '@tiptap/extension-youtube';
|
||||||
|
import Table from '@tiptap/extension-table';
|
||||||
|
import TableHeader from '@tiptap/extension-table-header';
|
||||||
import {
|
import {
|
||||||
Callout,
|
Callout,
|
||||||
Comment,
|
Comment,
|
||||||
@ -19,16 +21,18 @@ import {
|
|||||||
LinkExtension,
|
LinkExtension,
|
||||||
MathBlock,
|
MathBlock,
|
||||||
MathInline,
|
MathInline,
|
||||||
Table,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
TableRow,
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateHTML, generateJSON } from '@tiptap/html';
|
|
||||||
import { generateText, JSONContent } from '@tiptap/core';
|
import { generateText, JSONContent } from '@tiptap/core';
|
||||||
|
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||||
|
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||||
|
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||||
|
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||||
|
import { generateJSON } from '@tiptap/html';
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
@ -60,7 +64,7 @@ export const tiptapExtensions = [
|
|||||||
Callout,
|
Callout,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: JSONContent) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
return generateHTML(tiptapJson, tiptapExtensions);
|
return generateHTML(tiptapJson, tiptapExtensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { Extensions, getSchema, JSONContent } from '@tiptap/core';
|
||||||
|
import { DOMSerializer, Node } from '@tiptap/pm/model';
|
||||||
|
import { Window } from 'happy-dom';
|
||||||
|
|
||||||
|
export function generateHTML(doc: JSONContent, extensions: Extensions): string {
|
||||||
|
const schema = getSchema(extensions);
|
||||||
|
const contentNode = Node.fromJSON(schema, doc);
|
||||||
|
|
||||||
|
const window = new Window();
|
||||||
|
|
||||||
|
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
|
||||||
|
contentNode.content,
|
||||||
|
{
|
||||||
|
document: window.document as unknown as Document,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const serializer = new window.XMLSerializer();
|
||||||
|
// @ts-ignore
|
||||||
|
return serializer.serializeToString(fragment as unknown as Node);
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
import { Extensions, getSchema } from '@tiptap/core';
|
||||||
|
import { DOMParser, ParseOptions } from '@tiptap/pm/model';
|
||||||
|
import { Window, DOMParser as HappyDomParser } from 'happy-dom';
|
||||||
|
|
||||||
|
export function generateJSON(
|
||||||
|
html: string,
|
||||||
|
extensions: Extensions,
|
||||||
|
options?: ParseOptions,
|
||||||
|
): Record<string, any> {
|
||||||
|
const schema = getSchema(extensions);
|
||||||
|
|
||||||
|
const window = new Window();
|
||||||
|
const dom = new HappyDomParser(window).parseFromString(
|
||||||
|
html,
|
||||||
|
'text/html',
|
||||||
|
).body;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return DOMParser.fromSchema(schema).parse(dom, options).toJSON();
|
||||||
|
}
|
||||||
2
apps/server/src/common/helpers/prosemirror/html/index.ts
Normal file
2
apps/server/src/common/helpers/prosemirror/html/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './generateHTML.js';
|
||||||
|
export * from './generateJSON.js';
|
||||||
@ -9,7 +9,7 @@ import { Observable } from 'rxjs';
|
|||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AttachmentInterceptor implements NestInterceptor {
|
export class FileInterceptor implements NestInterceptor {
|
||||||
public intercept(
|
public intercept(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
next: CallHandler,
|
next: CallHandler,
|
||||||
@ -16,7 +16,15 @@ export class TransformHttpResponseInterceptor<T>
|
|||||||
intercept(
|
intercept(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
next: CallHandler<T>,
|
next: CallHandler<T>,
|
||||||
): Observable<Response<T>> {
|
): Observable<Response<T> | any> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const path = request.url;
|
||||||
|
|
||||||
|
// Skip interceptor for the /api/health path
|
||||||
|
if (path === '/api/health') {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data) => {
|
map((data) => {
|
||||||
const status = context.switchToHttp().getResponse().statusCode;
|
const status = context.switchToHttp().getResponse().statusCode;
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AttachmentService } from './services/attachment.service';
|
import { AttachmentService } from './services/attachment.service';
|
||||||
import { FastifyReply } from 'fastify';
|
import { FastifyReply } from 'fastify';
|
||||||
import { AttachmentInterceptor } from './interceptors/attachment.interceptor';
|
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||||
import * as bytes from 'bytes';
|
import * as bytes from 'bytes';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
@ -63,7 +63,7 @@ export class AttachmentController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('files/upload')
|
@Post('files/upload')
|
||||||
@UseInterceptors(AttachmentInterceptor)
|
@UseInterceptors(FileInterceptor)
|
||||||
async uploadFile(
|
async uploadFile(
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
@Res() res: FastifyReply,
|
@Res() res: FastifyReply,
|
||||||
@ -176,7 +176,7 @@ export class AttachmentController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('attachments/upload-image')
|
@Post('attachments/upload-image')
|
||||||
@UseInterceptors(AttachmentInterceptor)
|
@UseInterceptors(FileInterceptor)
|
||||||
async uploadAvatarOrLogo(
|
async uploadAvatarOrLogo(
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
@Res() res: FastifyReply,
|
@Res() res: FastifyReply,
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export class AttachmentService {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// delete uploaded file on error
|
// delete uploaded file on error
|
||||||
console.error(err);
|
this.logger.error(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
return attachment;
|
return attachment;
|
||||||
|
|||||||
@ -34,7 +34,10 @@ export class CoreModule implements NestModule {
|
|||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
consumer
|
consumer
|
||||||
.apply(DomainMiddleware)
|
.apply(DomainMiddleware)
|
||||||
.exclude({ path: 'auth/setup', method: RequestMethod.POST })
|
.exclude(
|
||||||
|
{ path: 'auth/setup', method: RequestMethod.POST },
|
||||||
|
{ path: 'health', method: RequestMethod.GET },
|
||||||
|
)
|
||||||
.forRoutes('*');
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -217,11 +218,21 @@ export class WorkspaceService {
|
|||||||
) {
|
) {
|
||||||
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
|
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
|
||||||
|
|
||||||
|
const newRole = userRoleDto.role.toLowerCase();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException('Workspace member not found');
|
throw new BadRequestException('Workspace member not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role === userRoleDto.role) {
|
// prevent ADMIN from managing OWNER role
|
||||||
|
if (
|
||||||
|
(authUser.role === UserRole.ADMIN && newRole === UserRole.OWNER) ||
|
||||||
|
(authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === newRole) {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,7 +249,7 @@ export class WorkspaceService {
|
|||||||
|
|
||||||
await this.userRepo.updateUser(
|
await this.userRepo.updateUser(
|
||||||
{
|
{
|
||||||
role: userRoleDto.role,
|
role: newRole,
|
||||||
},
|
},
|
||||||
user.id,
|
user.id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
|||||||
@ -36,6 +36,8 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
dialect: new PostgresDialect({
|
dialect: new PostgresDialect({
|
||||||
pool: new Pool({
|
pool: new Pool({
|
||||||
connectionString: environmentService.getDatabaseURL(),
|
connectionString: environmentService.getDatabaseURL(),
|
||||||
|
}).on('error', (err) => {
|
||||||
|
console.error('Database error:', err.message);
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
plugins: [new CamelCasePlugin()],
|
plugins: [new CamelCasePlugin()],
|
||||||
@ -102,7 +104,7 @@ export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async establishConnection() {
|
async establishConnection() {
|
||||||
const retryAttempts = 10;
|
const retryAttempts = 15;
|
||||||
const retryDelay = 3000;
|
const retryDelay = 3000;
|
||||||
|
|
||||||
this.logger.log('Establishing database connection');
|
this.logger.log('Establishing database connection');
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { type Kysely } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropIndex('pages_slug_id_idx').ifExists().execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {}
|
||||||
@ -6,15 +6,11 @@ import { hashPassword } from '../../../common/helpers';
|
|||||||
import { dbOrTx } from '@docmost/db/utils';
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
import {
|
import {
|
||||||
InsertableUser,
|
InsertableUser,
|
||||||
Space,
|
|
||||||
UpdatableUser,
|
UpdatableUser,
|
||||||
User,
|
User,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import {
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
executeWithPagination,
|
|
||||||
PaginationResult,
|
|
||||||
} from '@docmost/db/pagination/pagination';
|
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -66,7 +62,7 @@ export class UserRepo {
|
|||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.$if(includePassword, (qb) => qb.select('password'))
|
.$if(includePassword, (qb) => qb.select('password'))
|
||||||
.where('email', '=', email)
|
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,6 +87,13 @@ export class EnvironmentService {
|
|||||||
return parseInt(this.configService.get<string>('SMTP_PORT'));
|
return parseInt(this.configService.get<string>('SMTP_PORT'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSmtpSecure(): boolean {
|
||||||
|
const secure = this.configService
|
||||||
|
.get<string>('SMTP_SECURE', 'false')
|
||||||
|
.toLowerCase();
|
||||||
|
return secure === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
getSmtpUsername(): string {
|
getSmtpUsername(): string {
|
||||||
return this.configService.get<string>('SMTP_USERNAME');
|
return this.configService.get<string>('SMTP_USERNAME');
|
||||||
}
|
}
|
||||||
|
|||||||
26
apps/server/src/integrations/export/dto/export-dto.ts
Normal file
26
apps/server/src/integrations/export/dto/export-dto.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export enum ExportFormat {
|
||||||
|
HTML = 'html',
|
||||||
|
Markdown = 'markdown',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExportPageDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
pageId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['html', 'markdown'])
|
||||||
|
format: ExportFormat;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
includeFiles?: boolean;
|
||||||
|
}
|
||||||
69
apps/server/src/integrations/export/export.controller.ts
Normal file
69
apps/server/src/integrations/export/export.controller.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ExportService } from './export.service';
|
||||||
|
import { ExportPageDto } from './dto/export-dto';
|
||||||
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from '../../core/casl/interfaces/space-ability.type';
|
||||||
|
import { FastifyReply } from 'fastify';
|
||||||
|
import { sanitize } from 'sanitize-filename-ts';
|
||||||
|
import { getExportExtension } from './utils';
|
||||||
|
import { getMimeType } from '../../common/helpers';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class ImportController {
|
||||||
|
constructor(
|
||||||
|
private readonly exportService: ExportService,
|
||||||
|
private readonly pageRepo: PageRepo,
|
||||||
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('pages/export')
|
||||||
|
async exportPage(
|
||||||
|
@Body() dto: ExportPageDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@Res() res: FastifyReply,
|
||||||
|
) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId, {
|
||||||
|
includeContent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawContent = await this.exportService.exportPage(dto.format, page);
|
||||||
|
|
||||||
|
const fileExt = getExportExtension(dto.format);
|
||||||
|
const fileName = sanitize(page.title || 'Untitled') + fileExt;
|
||||||
|
|
||||||
|
res.headers({
|
||||||
|
'Content-Type': getMimeType(fileExt),
|
||||||
|
'Content-Disposition': 'attachment; filename="' + fileName + '"',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send(rawContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/server/src/integrations/export/export.module.ts
Normal file
9
apps/server/src/integrations/export/export.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ExportService } from './export.service';
|
||||||
|
import { ImportController } from './export.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [ExportService],
|
||||||
|
controllers: [ImportController],
|
||||||
|
})
|
||||||
|
export class ExportModule {}
|
||||||
34
apps/server/src/integrations/export/export.service.ts
Normal file
34
apps/server/src/integrations/export/export.service.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { jsonToHtml } from '../../collaboration/collaboration.util';
|
||||||
|
import { turndown } from './turndown-utils';
|
||||||
|
import { ExportFormat } from './dto/export-dto';
|
||||||
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExportService {
|
||||||
|
async exportPage(format: string, page: Page) {
|
||||||
|
const titleNode = {
|
||||||
|
type: 'heading',
|
||||||
|
attrs: { level: 1 },
|
||||||
|
content: [{ type: 'text', text: page.title }],
|
||||||
|
};
|
||||||
|
|
||||||
|
let prosemirrorJson: any = page.content || { type: 'doc', content: [] };
|
||||||
|
|
||||||
|
if (page.title) {
|
||||||
|
prosemirrorJson.content.unshift(titleNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageHtml = jsonToHtml(prosemirrorJson);
|
||||||
|
|
||||||
|
if (format === ExportFormat.HTML) {
|
||||||
|
return `<!DOCTYPE html><html><head><title>${page.title}</title></head><body>${pageHtml}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === ExportFormat.Markdown) {
|
||||||
|
return turndown(pageHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
apps/server/src/integrations/export/turndown-utils.ts
Normal file
122
apps/server/src/integrations/export/turndown-utils.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import * as TurndownService from '@joplin/turndown';
|
||||||
|
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
|
||||||
|
|
||||||
|
export function turndown(html: string): string {
|
||||||
|
const turndownService = new TurndownService({
|
||||||
|
headingStyle: 'atx',
|
||||||
|
codeBlockStyle: 'fenced',
|
||||||
|
hr: '---',
|
||||||
|
bulletListMarker: '-',
|
||||||
|
});
|
||||||
|
const tables = TurndownPluginGfm.tables;
|
||||||
|
const strikethrough = TurndownPluginGfm.strikethrough;
|
||||||
|
const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock;
|
||||||
|
|
||||||
|
turndownService.use([
|
||||||
|
tables,
|
||||||
|
strikethrough,
|
||||||
|
highlightedCodeBlock,
|
||||||
|
taskList,
|
||||||
|
callout,
|
||||||
|
preserveDetail,
|
||||||
|
listParagraph,
|
||||||
|
mathInline,
|
||||||
|
mathBlock,
|
||||||
|
]);
|
||||||
|
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function listParagraph(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('paragraph', {
|
||||||
|
filter: ['p'],
|
||||||
|
replacement: (content: any, node: HTMLInputElement) => {
|
||||||
|
if (node.parentElement?.nodeName === 'LI') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `\n\n${content}\n\n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function callout(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('callout', {
|
||||||
|
filter: function (node: HTMLInputElement) {
|
||||||
|
return (
|
||||||
|
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
|
const calloutType = node.getAttribute('data-callout-type');
|
||||||
|
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskList(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('taskListItem', {
|
||||||
|
filter: function (node: HTMLInputElement) {
|
||||||
|
return (
|
||||||
|
node.getAttribute('data-type') === 'taskItem' &&
|
||||||
|
node.parentNode.nodeName === 'UL'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
|
const checkbox = node.querySelector(
|
||||||
|
'input[type="checkbox"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const isChecked = checkbox.checked;
|
||||||
|
|
||||||
|
return `- ${isChecked ? '[x]' : '[ ]'} ${content.trim()} \n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function preserveDetail(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('preserveDetail', {
|
||||||
|
filter: function (node: HTMLInputElement) {
|
||||||
|
return node.nodeName === 'DETAILS';
|
||||||
|
},
|
||||||
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
|
// TODO: preserve summary of nested details
|
||||||
|
const summary = node.querySelector(':scope > summary');
|
||||||
|
let detailSummary = '';
|
||||||
|
|
||||||
|
if (summary) {
|
||||||
|
detailSummary = `<summary>${turndownService.turndown(summary.innerHTML)}</summary>`;
|
||||||
|
summary.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailsContent = turndownService.turndown(node.innerHTML);
|
||||||
|
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mathInline(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('mathInline', {
|
||||||
|
filter: function (node: HTMLInputElement) {
|
||||||
|
return (
|
||||||
|
node.nodeName === 'SPAN' &&
|
||||||
|
node.getAttribute('data-type') === 'mathInline'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
|
return `$${content}$`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mathBlock(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('mathBlock', {
|
||||||
|
filter: function (node: HTMLInputElement) {
|
||||||
|
return (
|
||||||
|
node.nodeName === 'DIV' &&
|
||||||
|
node.getAttribute('data-type') === 'mathBlock'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
|
return `\n$$${content}$$\n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
12
apps/server/src/integrations/export/utils.ts
Normal file
12
apps/server/src/integrations/export/utils.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { ExportFormat } from './dto/export-dto';
|
||||||
|
|
||||||
|
export function getExportExtension(format: string) {
|
||||||
|
if (format === ExportFormat.HTML) {
|
||||||
|
return '.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === ExportFormat.Markdown) {
|
||||||
|
return '.md';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
22
apps/server/src/integrations/health/health.controller.ts
Normal file
22
apps/server/src/integrations/health/health.controller.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
|
||||||
|
import { PostgresHealthIndicator } from './postgres.health';
|
||||||
|
import { RedisHealthIndicator } from './redis.health';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(
|
||||||
|
private health: HealthCheckService,
|
||||||
|
private postgres: PostgresHealthIndicator,
|
||||||
|
private redis: RedisHealthIndicator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@HealthCheck()
|
||||||
|
async check() {
|
||||||
|
return this.health.check([
|
||||||
|
() => this.postgres.pingCheck('database'),
|
||||||
|
() => this.redis.pingCheck('redis'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/server/src/integrations/health/health.module.ts
Normal file
13
apps/server/src/integrations/health/health.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { TerminusModule } from '@nestjs/terminus';
|
||||||
|
import { PostgresHealthIndicator } from './postgres.health';
|
||||||
|
import { RedisHealthIndicator } from './redis.health';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [PostgresHealthIndicator, RedisHealthIndicator],
|
||||||
|
imports: [TerminusModule],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
31
apps/server/src/integrations/health/postgres.health.ts
Normal file
31
apps/server/src/integrations/health/postgres.health.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import {
|
||||||
|
HealthCheckError,
|
||||||
|
HealthIndicator,
|
||||||
|
HealthIndicatorResult,
|
||||||
|
} from '@nestjs/terminus';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PostgresHealthIndicator extends HealthIndicator {
|
||||||
|
private readonly logger = new Logger(PostgresHealthIndicator.name);
|
||||||
|
|
||||||
|
constructor(@InjectKysely() private readonly db: KyselyDB) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pingCheck(key: string): Promise<HealthIndicatorResult> {
|
||||||
|
try {
|
||||||
|
await sql`SELECT 1=1`.execute(this.db);
|
||||||
|
return this.getStatus(key, true);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(JSON.stringify(e));
|
||||||
|
throw new HealthCheckError(
|
||||||
|
`${key} is not available`,
|
||||||
|
this.getStatus(key, false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/server/src/integrations/health/redis.health.ts
Normal file
34
apps/server/src/integrations/health/redis.health.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
HealthCheckError,
|
||||||
|
HealthIndicator,
|
||||||
|
HealthIndicatorResult,
|
||||||
|
} from '@nestjs/terminus';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisHealthIndicator extends HealthIndicator {
|
||||||
|
private readonly logger = new Logger(RedisHealthIndicator.name);
|
||||||
|
|
||||||
|
constructor(private environmentService: EnvironmentService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pingCheck(key: string): Promise<HealthIndicatorResult> {
|
||||||
|
try {
|
||||||
|
const redis = new Redis(this.environmentService.getRedisUrl(), {
|
||||||
|
maxRetriesPerRequest: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
await redis.ping();
|
||||||
|
return this.getStatus(key, true);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(e);
|
||||||
|
throw new HealthCheckError(
|
||||||
|
`${key} is not available`,
|
||||||
|
this.getStatus(key, false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/server/src/integrations/import/dto/import-dto.ts
Normal file
4
apps/server/src/integrations/import/dto/import-dto.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum ImportFormat {
|
||||||
|
HTML = 'html',
|
||||||
|
Markdown = 'markdown',
|
||||||
|
}
|
||||||
87
apps/server/src/integrations/import/import.controller.ts
Normal file
87
apps/server/src/integrations/import/import.controller.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from '../../core/casl/interfaces/space-ability.type';
|
||||||
|
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||||
|
import * as bytes from 'bytes';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { MAX_FILE_SIZE } from '../../core/attachment/attachment.constants';
|
||||||
|
import { ImportService } from './import.service';
|
||||||
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class ImportController {
|
||||||
|
private readonly logger = new Logger(ImportController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly importService: ImportService,
|
||||||
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@UseInterceptors(FileInterceptor)
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('pages/import')
|
||||||
|
async importPage(
|
||||||
|
@Req() req: any,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const validFileExtensions = ['.md', '.html'];
|
||||||
|
|
||||||
|
const maxFileSize = bytes(MAX_FILE_SIZE);
|
||||||
|
|
||||||
|
let file = null;
|
||||||
|
try {
|
||||||
|
file = await req.file({
|
||||||
|
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(err.message);
|
||||||
|
if (err?.statusCode === 413) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`File too large. Exceeds the ${MAX_FILE_SIZE} limit`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Failed to upload file');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!validFileExtensions.includes(path.extname(file.filename).toLowerCase())
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Invalid import file type.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceId = file.fields?.spaceId?.value;
|
||||||
|
|
||||||
|
if (!spaceId) {
|
||||||
|
throw new BadRequestException('spaceId or format not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.importService.importPage(file, user.id, spaceId, workspace.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/server/src/integrations/import/import.module.ts
Normal file
9
apps/server/src/integrations/import/import.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ImportService } from './import.service';
|
||||||
|
import { ImportController } from './import.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [ImportService],
|
||||||
|
controllers: [ImportController]
|
||||||
|
})
|
||||||
|
export class ImportModule {}
|
||||||
165
apps/server/src/integrations/import/import.service.ts
Normal file
165
apps/server/src/integrations/import/import.service.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { MultipartFile } from '@fastify/multipart';
|
||||||
|
import { sanitize } from 'sanitize-filename-ts';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
htmlToJson,
|
||||||
|
tiptapExtensions,
|
||||||
|
} from '../../collaboration/collaboration.util';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { generateSlugId } from '../../common/helpers';
|
||||||
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
|
import { markdownToHtml } from './utils/marked.utils';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImportService {
|
||||||
|
private readonly logger = new Logger(ImportService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly pageRepo: PageRepo,
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async importPage(
|
||||||
|
filePromise: Promise<MultipartFile>,
|
||||||
|
userId: string,
|
||||||
|
spaceId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const file = await filePromise;
|
||||||
|
const fileBuffer = await file.toBuffer();
|
||||||
|
const fileName = sanitize(file.filename).slice(0, 255).split('.')[0];
|
||||||
|
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||||
|
const fileMimeType = file.mimetype;
|
||||||
|
const fileContent = fileBuffer.toString();
|
||||||
|
|
||||||
|
let prosemirrorState = null;
|
||||||
|
let createdPage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fileExtension.endsWith('.md') && fileMimeType === 'text/markdown') {
|
||||||
|
prosemirrorState = await this.processMarkdown(fileContent);
|
||||||
|
} else if (
|
||||||
|
fileExtension.endsWith('.html') &&
|
||||||
|
fileMimeType === 'text/html'
|
||||||
|
) {
|
||||||
|
prosemirrorState = await this.processHTML(fileContent);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = 'Error processing file content';
|
||||||
|
this.logger.error(message, err);
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prosemirrorState) {
|
||||||
|
const message = 'Failed to create ProseMirror state';
|
||||||
|
this.logger.error(message);
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, prosemirrorJson } =
|
||||||
|
this.extractTitleAndRemoveHeading(prosemirrorState);
|
||||||
|
|
||||||
|
const pageTitle = title || fileName;
|
||||||
|
|
||||||
|
if (prosemirrorJson) {
|
||||||
|
try {
|
||||||
|
const pagePosition = await this.getNewPagePosition(spaceId);
|
||||||
|
|
||||||
|
createdPage = await this.pageRepo.insertPage({
|
||||||
|
slugId: generateSlugId(),
|
||||||
|
title: pageTitle,
|
||||||
|
content: prosemirrorJson,
|
||||||
|
ydoc: await this.createYdoc(prosemirrorJson),
|
||||||
|
position: pagePosition,
|
||||||
|
spaceId: spaceId,
|
||||||
|
creatorId: userId,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
lastUpdatedById: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Successfully imported "${title}${fileExtension}. ID: ${createdPage.id} - SlugId: ${createdPage.slugId}"`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = 'Failed to create imported page';
|
||||||
|
this.logger.error(message, err);
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async processMarkdown(markdownInput: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const html = await markdownToHtml(markdownInput);
|
||||||
|
return this.processHTML(html);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processHTML(htmlInput: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
return htmlToJson(htmlInput);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
|
||||||
|
if (prosemirrorJson) {
|
||||||
|
this.logger.debug(`Converting prosemirror json state to ydoc`);
|
||||||
|
|
||||||
|
const ydoc = TiptapTransformer.toYdoc(
|
||||||
|
prosemirrorJson,
|
||||||
|
'default',
|
||||||
|
tiptapExtensions,
|
||||||
|
);
|
||||||
|
|
||||||
|
Y.encodeStateAsUpdate(ydoc);
|
||||||
|
|
||||||
|
return Buffer.from(Y.encodeStateAsUpdate(ydoc));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
extractTitleAndRemoveHeading(prosemirrorState: any) {
|
||||||
|
let title = null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
prosemirrorState?.content?.length > 0 &&
|
||||||
|
prosemirrorState.content[0].type === 'heading' &&
|
||||||
|
prosemirrorState.content[0].attrs?.level === 1
|
||||||
|
) {
|
||||||
|
title = prosemirrorState.content[0].content[0].text;
|
||||||
|
|
||||||
|
// remove h1 header node from state
|
||||||
|
prosemirrorState.content.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, prosemirrorJson: prosemirrorState };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNewPagePosition(spaceId: string): Promise<string> {
|
||||||
|
const lastPage = await this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'position'])
|
||||||
|
.where('spaceId', '=', spaceId)
|
||||||
|
.orderBy('position', 'desc')
|
||||||
|
.limit(1)
|
||||||
|
.where('parentPageId', 'is', null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (lastPage) {
|
||||||
|
return generateJitteredKeyBetween(lastPage.position, null);
|
||||||
|
} else {
|
||||||
|
return generateJitteredKeyBetween(null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/server/src/integrations/import/utils/marked.utils.ts
Normal file
36
apps/server/src/integrations/import/utils/marked.utils.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
marked.use({
|
||||||
|
renderer: {
|
||||||
|
// @ts-ignore
|
||||||
|
list(body: string, isOrdered: boolean, start: number) {
|
||||||
|
if (isOrdered) {
|
||||||
|
const startAttr = start !== 1 ? ` start="${start}"` : '';
|
||||||
|
return `<ol ${startAttr}>\n${body}</ol>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = body.includes(`<input`) ? ' data-type="taskList"' : '';
|
||||||
|
return `<ul${dataType}>\n${body}</ul>\n`;
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
listitem({ text, raw, task: isTask, checked: isChecked }): string {
|
||||||
|
if (!isTask) {
|
||||||
|
return `<li>${text}</li>\n`;
|
||||||
|
}
|
||||||
|
const checkedAttr = isChecked
|
||||||
|
? 'data-checked="true"'
|
||||||
|
: 'data-checked="false"';
|
||||||
|
return `<li data-type="taskItem" ${checkedAttr}>${text}</li>\n`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function markdownToHtml(markdownInput: string): Promise<string> {
|
||||||
|
const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/;
|
||||||
|
|
||||||
|
const markdown = markdownInput
|
||||||
|
.replace(YAML_FONT_MATTER_REGEX, '')
|
||||||
|
.trimStart();
|
||||||
|
|
||||||
|
return marked.parse(markdown);
|
||||||
|
}
|
||||||
@ -26,16 +26,21 @@ export const mailDriverConfigProvider = {
|
|||||||
|
|
||||||
switch (driver) {
|
switch (driver) {
|
||||||
case MailOption.SMTP:
|
case MailOption.SMTP:
|
||||||
|
let auth = undefined;
|
||||||
|
if (environmentService.getSmtpUsername() && environmentService.getSmtpPassword()) {
|
||||||
|
auth = {
|
||||||
|
user: environmentService.getSmtpUsername(),
|
||||||
|
pass: environmentService.getSmtpPassword(),
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
driver,
|
driver,
|
||||||
config: {
|
config: {
|
||||||
host: environmentService.getSmtpHost(),
|
host: environmentService.getSmtpHost(),
|
||||||
port: environmentService.getSmtpPort(),
|
port: environmentService.getSmtpPort(),
|
||||||
connectionTimeout: 30 * 1000, // 30 seconds
|
connectionTimeout: 30 * 1000, // 30 seconds
|
||||||
auth: {
|
auth,
|
||||||
user: environmentService.getSmtpUsername(),
|
secure: environmentService.getSmtpSecure(),
|
||||||
pass: environmentService.getSmtpPassword(),
|
|
||||||
},
|
|
||||||
} as SMTPTransport.Options,
|
} as SMTPTransport.Options,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -34,9 +34,6 @@ export class S3Driver implements StorageDriver {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.s3Client.send(command);
|
await this.s3Client.send(command);
|
||||||
// we can get the path from location
|
|
||||||
|
|
||||||
console.log(`File uploaded successfully: ${filePath}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,20 +41,34 @@ export const storageDriverConfigProvider = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
case StorageOption.S3:
|
case StorageOption.S3:
|
||||||
return {
|
const s3Config = {
|
||||||
driver,
|
driver,
|
||||||
config: {
|
config: {
|
||||||
region: environmentService.getAwsS3Region(),
|
region: environmentService.getAwsS3Region(),
|
||||||
endpoint: environmentService.getAwsS3Endpoint(),
|
endpoint: environmentService.getAwsS3Endpoint(),
|
||||||
bucket: environmentService.getAwsS3Bucket(),
|
bucket: environmentService.getAwsS3Bucket(),
|
||||||
baseUrl: environmentService.getAwsS3Url(),
|
baseUrl: environmentService.getAwsS3Url(),
|
||||||
credentials: {
|
credentials: undefined,
|
||||||
accessKeyId: environmentService.getAwsS3AccessKeyId(),
|
|
||||||
secretAccessKey: environmentService.getAwsS3SecretAccessKey(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This makes use of AWS_S3_ACCESS_KEY_ID and AWS_S3_SECRET_ACCESS_KEY if present,
|
||||||
|
* If not present, it makes it lenient for the AWS SDK to use
|
||||||
|
* AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY if they are present in the environment
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
environmentService.getAwsS3AccessKeyId() ||
|
||||||
|
environmentService.getAwsS3SecretAccessKey()
|
||||||
|
) {
|
||||||
|
s3Config.config.credentials = {
|
||||||
|
accessKeyId: environmentService.getAwsS3AccessKeyId(),
|
||||||
|
secretAccessKey: environmentService.getAwsS3SecretAccessKey(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return s3Config;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown storage driver: ${driver}`);
|
throw new Error(`Unknown storage driver: ${driver}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { STORAGE_DRIVER_TOKEN } from './constants/storage.constants';
|
import { STORAGE_DRIVER_TOKEN } from './constants/storage.constants';
|
||||||
import { StorageDriver } from './interfaces';
|
import { StorageDriver } from './interfaces';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StorageService {
|
export class StorageService {
|
||||||
|
private readonly logger = new Logger(StorageService.name);
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver,
|
@Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async upload(filePath: string, fileContent: Buffer | any) {
|
async upload(filePath: string, fileContent: Buffer | any) {
|
||||||
await this.storageDriver.upload(filePath, fileContent);
|
await this.storageDriver.upload(filePath, fileContent);
|
||||||
|
this.logger.debug(`File uploaded successfully. Path: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async read(filePath: string): Promise<Buffer> {
|
async read(filePath: string): Promise<Buffer> {
|
||||||
|
|||||||
@ -40,7 +40,8 @@ async function bootstrap() {
|
|||||||
.addHook('preHandler', function (req, reply, done) {
|
.addHook('preHandler', function (req, reply, done) {
|
||||||
if (
|
if (
|
||||||
req.originalUrl.startsWith('/api') &&
|
req.originalUrl.startsWith('/api') &&
|
||||||
!req.originalUrl.startsWith('/api/auth/setup')
|
!req.originalUrl.startsWith('/api/auth/setup') &&
|
||||||
|
!req.originalUrl.startsWith('/api/health')
|
||||||
) {
|
) {
|
||||||
if (!req.raw?.['workspaceId']) {
|
if (!req.raw?.['workspaceId']) {
|
||||||
throw new NotFoundException('Workspace not found');
|
throw new NotFoundException('Workspace not found');
|
||||||
@ -59,15 +60,7 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
app.enableCors();
|
||||||
// make development easy
|
|
||||||
app.enableCors({
|
|
||||||
origin: ['http://localhost:5173'],
|
|
||||||
credentials: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
app.enableCors();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.useGlobalInterceptors(new TransformHttpResponseInterceptor());
|
app.useGlobalInterceptors(new TransformHttpResponseInterceptor());
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
|||||||
84
package.json
84
package.json
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "docmost",
|
"name": "docmost",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"version": "0.2.3",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nx run-many -t build",
|
"build": "nx run-many -t build",
|
||||||
@ -12,49 +12,52 @@
|
|||||||
"client:dev": "nx run client:dev",
|
"client:dev": "nx run client:dev",
|
||||||
"server:dev": "nx run server:start:dev",
|
"server:dev": "nx run server:start:dev",
|
||||||
"server:start": "nx run server:start:prod",
|
"server:start": "nx run server:start:prod",
|
||||||
"email:dev": "nx run @docmost/transactional:dev"
|
"email:dev": "nx run @docmost/transactional:dev",
|
||||||
|
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@hocuspocus/extension-redis": "^2.13.2",
|
"@hocuspocus/extension-redis": "^2.13.5",
|
||||||
"@hocuspocus/provider": "^2.13.2",
|
"@hocuspocus/provider": "^2.13.5",
|
||||||
"@hocuspocus/server": "^2.13.2",
|
"@hocuspocus/server": "^2.13.5",
|
||||||
"@hocuspocus/transformer": "^2.13.2",
|
"@hocuspocus/transformer": "^2.13.5",
|
||||||
|
"@joplin/turndown": "^4.0.74",
|
||||||
|
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
||||||
"@sindresorhus/slugify": "^2.2.1",
|
"@sindresorhus/slugify": "^2.2.1",
|
||||||
"@tiptap/core": "^2.4.0",
|
"@tiptap/core": "^2.5.4",
|
||||||
"@tiptap/extension-code-block": "^2.4.0",
|
"@tiptap/extension-code-block": "^2.5.4",
|
||||||
"@tiptap/extension-collaboration": "^2.4.0",
|
"@tiptap/extension-collaboration": "^2.5.4",
|
||||||
"@tiptap/extension-collaboration-cursor": "^2.4.0",
|
"@tiptap/extension-collaboration-cursor": "^2.5.4",
|
||||||
"@tiptap/extension-color": "^2.4.0",
|
"@tiptap/extension-color": "^2.5.4",
|
||||||
"@tiptap/extension-document": "^2.4.0",
|
"@tiptap/extension-document": "^2.5.4",
|
||||||
"@tiptap/extension-heading": "^2.4.0",
|
"@tiptap/extension-heading": "^2.5.4",
|
||||||
"@tiptap/extension-highlight": "^2.4.0",
|
"@tiptap/extension-highlight": "^2.5.4",
|
||||||
"@tiptap/extension-history": "^2.4.0",
|
"@tiptap/extension-history": "^2.5.4",
|
||||||
"@tiptap/extension-image": "^2.4.0",
|
"@tiptap/extension-image": "^2.5.4",
|
||||||
"@tiptap/extension-link": "^2.4.0",
|
"@tiptap/extension-link": "^2.5.4",
|
||||||
"@tiptap/extension-list-item": "^2.4.0",
|
"@tiptap/extension-list-item": "^2.5.4",
|
||||||
"@tiptap/extension-list-keymap": "^2.4.0",
|
"@tiptap/extension-list-keymap": "^2.5.4",
|
||||||
"@tiptap/extension-mention": "^2.4.0",
|
"@tiptap/extension-mention": "^2.5.4",
|
||||||
"@tiptap/extension-placeholder": "^2.4.0",
|
"@tiptap/extension-placeholder": "^2.5.4",
|
||||||
"@tiptap/extension-subscript": "^2.4.0",
|
"@tiptap/extension-subscript": "^2.5.4",
|
||||||
"@tiptap/extension-superscript": "^2.4.0",
|
"@tiptap/extension-superscript": "^2.5.4",
|
||||||
"@tiptap/extension-table": "^2.4.0",
|
"@tiptap/extension-table": "^2.5.4",
|
||||||
"@tiptap/extension-table-cell": "^2.4.0",
|
"@tiptap/extension-table-cell": "^2.5.4",
|
||||||
"@tiptap/extension-table-header": "^2.4.0",
|
"@tiptap/extension-table-header": "^2.5.4",
|
||||||
"@tiptap/extension-table-row": "^2.4.0",
|
"@tiptap/extension-table-row": "^2.5.4",
|
||||||
"@tiptap/extension-task-item": "^2.4.0",
|
"@tiptap/extension-task-item": "^2.5.4",
|
||||||
"@tiptap/extension-task-list": "^2.4.0",
|
"@tiptap/extension-task-list": "^2.5.4",
|
||||||
"@tiptap/extension-text": "^2.4.0",
|
"@tiptap/extension-text": "^2.5.4",
|
||||||
"@tiptap/extension-text-align": "^2.4.0",
|
"@tiptap/extension-text-align": "^2.5.4",
|
||||||
"@tiptap/extension-text-style": "^2.4.0",
|
"@tiptap/extension-text-style": "^2.5.4",
|
||||||
"@tiptap/extension-typography": "^2.4.0",
|
"@tiptap/extension-typography": "^2.5.4",
|
||||||
"@tiptap/extension-underline": "^2.4.0",
|
"@tiptap/extension-underline": "^2.5.4",
|
||||||
"@tiptap/extension-youtube": "^2.4.0",
|
"@tiptap/extension-youtube": "^2.5.4",
|
||||||
"@tiptap/html": "^2.4.0",
|
"@tiptap/html": "^2.5.4",
|
||||||
"@tiptap/pm": "^2.4.0",
|
"@tiptap/pm": "^2.5.4",
|
||||||
"@tiptap/react": "^2.4.0",
|
"@tiptap/react": "^2.5.4",
|
||||||
"@tiptap/starter-kit": "^2.4.0",
|
"@tiptap/starter-kit": "^2.5.4",
|
||||||
"@tiptap/suggestion": "^2.4.0",
|
"@tiptap/suggestion": "^2.5.4",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"fractional-indexing-jittered": "^0.9.1",
|
"fractional-indexing-jittered": "^0.9.1",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
@ -65,6 +68,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nx/js": "19.3.2",
|
"@nx/js": "19.3.2",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
"nx": "19.3.2",
|
"nx": "19.3.2",
|
||||||
"tsx": "^4.15.7"
|
"tsx": "^4.15.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
import { IAttachment } from "client/src/lib/types";
|
|
||||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||||
|
import { IAttachment } from "../types";
|
||||||
|
|
||||||
const uploadKey = new PluginKey("image-upload");
|
const uploadKey = new PluginKey("image-upload");
|
||||||
|
|
||||||
|
|||||||
@ -57,9 +57,9 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: "100%",
|
||||||
parseHTML: (element) => element.getAttribute("data-width"),
|
parseHTML: (element) => element.getAttribute("width"),
|
||||||
renderHTML: (attributes: ImageAttributes) => ({
|
renderHTML: (attributes: ImageAttributes) => ({
|
||||||
"data-width": attributes.width,
|
width: attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
align: {
|
align: {
|
||||||
|
|||||||
@ -36,7 +36,9 @@ export const MathBlock = Node.create({
|
|||||||
return {
|
return {
|
||||||
text: {
|
text: {
|
||||||
default: "",
|
default: "",
|
||||||
parseHTML: (element) => element.innerHTML.split("$")[1],
|
parseHTML: (element) => {
|
||||||
|
return element.innerHTML;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -44,7 +46,7 @@ export const MathBlock = Node.create({
|
|||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: "div",
|
tag: `div[data-type="${this.name}"]`,
|
||||||
getAttrs: (node: HTMLElement) => {
|
getAttrs: (node: HTMLElement) => {
|
||||||
return node.hasAttribute("data-katex") ? {} : false;
|
return node.hasAttribute("data-katex") ? {} : false;
|
||||||
},
|
},
|
||||||
@ -55,8 +57,8 @@ export const MathBlock = Node.create({
|
|||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return [
|
return [
|
||||||
"div",
|
"div",
|
||||||
{},
|
{ "data-type": this.name, "data-katex": true },
|
||||||
["div", { "data-katex": true }, `$${HTMLAttributes.text}$`],
|
`${HTMLAttributes.text}`,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -64,10 +66,6 @@ export const MathBlock = Node.create({
|
|||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
|
|
||||||
renderText({ node }) {
|
|
||||||
return node.attrs.text;
|
|
||||||
},
|
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
setMathBlock:
|
setMathBlock:
|
||||||
|
|||||||
@ -37,7 +37,9 @@ export const MathInline = Node.create<MathInlineOption>({
|
|||||||
return {
|
return {
|
||||||
text: {
|
text: {
|
||||||
default: "",
|
default: "",
|
||||||
parseHTML: (element) => element.innerHTML.split("$")[1],
|
parseHTML: (element) => {
|
||||||
|
return element.innerHTML;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -45,7 +47,7 @@ export const MathInline = Node.create<MathInlineOption>({
|
|||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: "span",
|
tag: `span[data-type="${this.name}"]`,
|
||||||
getAttrs: (node: HTMLElement) => {
|
getAttrs: (node: HTMLElement) => {
|
||||||
return node.hasAttribute("data-katex") ? {} : false;
|
return node.hasAttribute("data-katex") ? {} : false;
|
||||||
},
|
},
|
||||||
@ -55,16 +57,12 @@ export const MathInline = Node.create<MathInlineOption>({
|
|||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return [
|
return [
|
||||||
"div",
|
"span",
|
||||||
{},
|
{ "data-type": this.name, "data-katex": true },
|
||||||
["span", { "data-katex": true }, `$${HTMLAttributes.text}$`],
|
`${HTMLAttributes.text}`,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderText({ node }) {
|
|
||||||
return node.attrs.text;
|
|
||||||
},
|
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
import TiptapTableHeader from "@tiptap/extension-table-header";
|
|
||||||
|
|
||||||
export const TableHeader = TiptapTableHeader.configure();
|
|
||||||
@ -1,4 +1,2 @@
|
|||||||
export * from "./table-extension";
|
|
||||||
export * from "./header";
|
|
||||||
export * from "./row";
|
export * from "./row";
|
||||||
export * from "./cell";
|
export * from "./cell";
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
import TiptapTable from "@tiptap/extension-table";
|
|
||||||
|
|
||||||
export const Table = TiptapTable.configure({
|
|
||||||
resizable: true,
|
|
||||||
lastColumnResizable: false,
|
|
||||||
allowTableNodeSelection: true,
|
|
||||||
});
|
|
||||||
17
packages/editor-ext/src/lib/types.ts
Normal file
17
packages/editor-ext/src/lib/types.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// repetition for now
|
||||||
|
export interface IAttachment {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
fileSize: number;
|
||||||
|
fileExt: string;
|
||||||
|
mimeType: string;
|
||||||
|
type: string;
|
||||||
|
creatorId: string;
|
||||||
|
pageId: string | null;
|
||||||
|
spaceId: string | null;
|
||||||
|
workspaceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import { Editor, findParentNode, isTextSelection } from "@tiptap/core";
|
|||||||
import { Selection, Transaction } from "@tiptap/pm/state";
|
import { Selection, Transaction } from "@tiptap/pm/state";
|
||||||
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||||
import { Node, ResolvedPos } from "@tiptap/pm/model";
|
import { Node, ResolvedPos } from "@tiptap/pm/model";
|
||||||
import { Table } from "./table/table-extension";
|
import Table from "@tiptap/extension-table";
|
||||||
|
|
||||||
export const isRectSelected = (rect: any) => (selection: CellSelection) => {
|
export const isRectSelected = (rect: any) => (selection: CellSelection) => {
|
||||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
import { IAttachment } from "client/src/lib/types";
|
|
||||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||||
|
import { IAttachment } from "../types";
|
||||||
|
|
||||||
const uploadKey = new PluginKey("video-upload");
|
const uploadKey = new PluginKey("video-upload");
|
||||||
|
|
||||||
|
|||||||
@ -62,9 +62,9 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
|||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: "100%",
|
||||||
parseHTML: (element) => element.getAttribute("data-width"),
|
parseHTML: (element) => element.getAttribute("width"),
|
||||||
renderHTML: (attributes: VideoAttributes) => ({
|
renderHTML: (attributes: VideoAttributes) => ({
|
||||||
"data-width": attributes.width,
|
width: attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
1532
pnpm-lock.yaml
generated
1532
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user