mirror of
https://github.com/docmost/docmost.git
synced 2026-05-15 05:04:06 +08:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 290b7d9d94 | |||
| 2503bfd3a2 | |||
| f48d6dd60b | |||
| 1302b1b602 | |||
| 89a3f4cfc2 | |||
| e48b1c0dae | |||
| 4a2a5a7a4d | |||
| 532001fd82 | |||
| e6bf4cdd6c | |||
| a9a4a26db5 | |||
| ede5633415 | |||
| a25cf84671 | |||
| a37d558bac | |||
| ddb0f9225f | |||
| c717847ca8 | |||
| fe83557767 | |||
| 9fa432dba9 | |||
| c6aaefecbd | |||
| 311d81bc71 | |||
| f178e6654f | |||
| ca186f3c0e | |||
| a16d5d1bf4 | |||
| d97baf5824 | |||
| 8349d8271c | |||
| 2e6d16dbc3 | |||
| 4107793e73 | |||
| a1b6ac7f3e | |||
| dd0319a14d | |||
| 8194c7d42d | |||
| d01ced078b | |||
| da9c971050 | |||
| 4e7af507c6 | |||
| f7426a0b45 | |||
| b85b34d6b1 | |||
| e064e58f79 | |||
| 4f1a97ceb9 | |||
| d07338861b |
@@ -40,3 +40,5 @@ SMTP_IGNORETLS=false
|
|||||||
# Postmark driver config
|
# Postmark driver config
|
||||||
POSTMARK_TOKEN=
|
POSTMARK_TOKEN=
|
||||||
|
|
||||||
|
# for custom drawio server
|
||||||
|
DRAWIO_URL=
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: { browser: true, es2020: true },
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
'plugin:@tanstack/eslint-plugin-query/recommended',
|
|
||||||
],
|
|
||||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
plugins: ['react-refresh'],
|
|
||||||
rules: {
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
'@typescript-eslint/ban-ts-comment': 'off',
|
|
||||||
'@typescript-eslint/no-unused-vars': 'off',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import pluginQuery from "@tanstack/eslint-plugin-query";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
"@tanstack/query": pluginQuery,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"react-hooks/exhaustive-deps": "off",
|
||||||
|
"@typescript-eslint/no-unused-expressions": "off",
|
||||||
|
"no-useless-escape": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
+40
-37
@@ -1,73 +1,76 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.0",
|
"version": "0.6.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"lint": "eslint --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@casl/ability": "^6.7.1",
|
"@casl/ability": "^6.7.2",
|
||||||
"@casl/react": "^4.0.0",
|
"@casl/react": "^4.0.0",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "^0.17.6",
|
"@excalidraw/excalidraw": "^0.17.6",
|
||||||
"@mantine/core": "^7.12.2",
|
"@mantine/core": "^7.14.2",
|
||||||
"@mantine/form": "^7.12.2",
|
"@mantine/form": "^7.14.2",
|
||||||
"@mantine/hooks": "^7.12.2",
|
"@mantine/hooks": "^7.14.2",
|
||||||
"@mantine/modals": "^7.12.2",
|
"@mantine/modals": "^7.14.2",
|
||||||
"@mantine/notifications": "^7.12.2",
|
"@mantine/notifications": "^7.14.2",
|
||||||
"@mantine/spotlight": "^7.12.2",
|
"@mantine/spotlight": "^7.14.2",
|
||||||
"@tabler/icons-react": "^3.14.0",
|
"@tabler/icons-react": "^3.22.0",
|
||||||
"@tanstack/react-query": "^5.53.2",
|
"@tanstack/react-query": "^5.61.4",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^4.1.0",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jotai": "^2.9.3",
|
"jotai": "^2.10.3",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"katex": "^0.16.11",
|
"katex": "^0.16.11",
|
||||||
"lowlight": "^3.1.0",
|
"lowlight": "^3.2.0",
|
||||||
"mermaid": "^11.0.2",
|
"mermaid": "^11.4.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "^3.4.0",
|
"react-arborist": "^3.4.0",
|
||||||
"react-clear-modal": "^2.0.9",
|
"react-clear-modal": "^2.0.11",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "^0.2.0",
|
"react-drawio": "^1.0.1",
|
||||||
"react-error-boundary": "^4.0.13",
|
"react-error-boundary": "^4.1.2",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-router-dom": "^6.26.1",
|
"react-router-dom": "^7.0.1",
|
||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.8.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.12",
|
"tiptap-extension-global-drag-handle": "^0.1.16",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/eslint-plugin-query": "^5.53.0",
|
"@eslint/js": "^9.16.0",
|
||||||
|
"@tanstack/eslint-plugin-query": "^5.62.1",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@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": "22.5.2",
|
"@types/node": "22.10.0",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@typescript-eslint/parser": "^8.3.0",
|
"eslint": "^9.15.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"eslint-plugin-react": "^7.37.2",
|
||||||
"eslint": "^9.9.1",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
"eslint-plugin-react-refresh": "^0.4.11",
|
"globals": "^15.13.0",
|
||||||
"optics-ts": "^2.4.1",
|
"optics-ts": "^2.4.1",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.49",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.4.1",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^5.4.8"
|
"typescript-eslint": "^8.17.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Divider,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { exportSpace } from "@/features/space/services/space-service";
|
||||||
|
|
||||||
|
interface ExportModalProps {
|
||||||
|
id: string;
|
||||||
|
type: "space" | "page";
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExportModal({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: ExportModalProps) {
|
||||||
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
|
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||||
|
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
if (type === "page") {
|
||||||
|
await exportPage({ pageId: id, format, includeChildren });
|
||||||
|
}
|
||||||
|
if (type === "space") {
|
||||||
|
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||||
|
}
|
||||||
|
setIncludeChildren(false);
|
||||||
|
setIncludeAttachments(true);
|
||||||
|
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 {type}</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>
|
||||||
|
|
||||||
|
{type === "page" && (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="md">Include subpages</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
onChange={(event) =>
|
||||||
|
setIncludeChildren(event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
checked={includeChildren}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "space" && (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="md">Include attachments</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
onChange={(event) =>
|
||||||
|
setIncludeAttachments(event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
checked={includeAttachments}
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,3 +14,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resizeHandle {
|
||||||
|
width: 3px;
|
||||||
|
cursor: col-resize;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
&:hover, &:active {
|
||||||
|
width: 5px;
|
||||||
|
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { AppShell, Container } from "@mantine/core";
|
import { AppShell, Container } from "@mantine/core";
|
||||||
import React from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
asideStateAtom,
|
asideStateAtom,
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom, sidebarWidthAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
@@ -21,6 +21,46 @@ export default function GlobalAppShell({
|
|||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const sidebarRef = useRef(null);
|
||||||
|
|
||||||
|
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||||
|
mouseDownEvent.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopResizing = React.useCallback(() => {
|
||||||
|
setIsResizing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resize = React.useCallback(
|
||||||
|
(mouseMoveEvent) => {
|
||||||
|
if (isResizing) {
|
||||||
|
const newWidth = mouseMoveEvent.clientX - sidebarRef.current.getBoundingClientRect().left;
|
||||||
|
if (newWidth < 220) {
|
||||||
|
setSidebarWidth(220);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newWidth > 600) {
|
||||||
|
setSidebarWidth(600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSidebarWidth(newWidth);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isResizing]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
//https://codesandbox.io/p/sandbox/kz9de
|
||||||
|
window.addEventListener("mousemove", resize);
|
||||||
|
window.addEventListener("mouseup", stopResizing);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", resize);
|
||||||
|
window.removeEventListener("mouseup", stopResizing);
|
||||||
|
};
|
||||||
|
}, [resize, stopResizing]);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||||
@@ -33,7 +73,7 @@ export default function GlobalAppShell({
|
|||||||
header={{ height: 45 }}
|
header={{ height: 45 }}
|
||||||
navbar={
|
navbar={
|
||||||
!isHomeRoute && {
|
!isHomeRoute && {
|
||||||
width: 300,
|
width: isSpaceRoute ? sidebarWidth : 300,
|
||||||
breakpoint: "sm",
|
breakpoint: "sm",
|
||||||
collapsed: {
|
collapsed: {
|
||||||
mobile: !mobileOpened,
|
mobile: !mobileOpened,
|
||||||
@@ -54,7 +94,8 @@ export default function GlobalAppShell({
|
|||||||
<AppHeader />
|
<AppHeader />
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
{!isHomeRoute && (
|
{!isHomeRoute && (
|
||||||
<AppShell.Navbar className={classes.navbar} withBorder={false}>
|
<AppShell.Navbar className={classes.navbar} withBorder={false} ref={sidebarRef}>
|
||||||
|
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||||
{isSpaceRoute && <SpaceSidebar />}
|
{isSpaceRoute && <SpaceSidebar />}
|
||||||
{isSettingsRoute && <SettingsSidebar />}
|
{isSettingsRoute && <SettingsSidebar />}
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|||||||
@@ -19,3 +19,5 @@ export const asideStateAtom = atom<AsideStateType>({
|
|||||||
tab: "",
|
tab: "",
|
||||||
isAsideOpen: false,
|
isAsideOpen: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -30,6 +31,7 @@ export default function useAuth() {
|
|||||||
|
|
||||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||||
const [authToken, setAuthToken] = useAtom(authTokensAtom);
|
const [authToken, setAuthToken] = useAtom(authTokensAtom);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const handleSignIn = async (data: ILogin) => {
|
const handleSignIn = async (data: ILogin) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -136,7 +138,8 @@ export default function useAuth() {
|
|||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
Cookies.remove("authTokens");
|
Cookies.remove("authTokens");
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
queryClient.clear();
|
||||||
|
window.location.replace(APP_ROUTE.AUTH.LOGIN);;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleForgotPassword = async (data: IForgotPassword) => {
|
const handleForgotPassword = async (data: IForgotPassword) => {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export function useCommentsQuery(
|
|||||||
params: ICommentParams,
|
params: ICommentParams,
|
||||||
): UseQueryResult<IPagination<IComment>, Error> {
|
): UseQueryResult<IPagination<IComment>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
|
||||||
queryKey: RQ_KEY(params.pageId),
|
queryKey: RQ_KEY(params.pageId),
|
||||||
queryFn: () => getPageComments(params),
|
queryFn: () => getPageComments(params),
|
||||||
enabled: !!params.pageId,
|
enabled: !!params.pageId,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
showCommentPopupAtom,
|
showCommentPopupAtom,
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v7 as uuid7 } from "uuid";
|
||||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
name: "comment",
|
name: "comment",
|
||||||
isActive: () => props.editor.isActive("comment"),
|
isActive: () => props.editor.isActive("comment"),
|
||||||
command: () => {
|
command: () => {
|
||||||
const commentId = uuidv4();
|
const commentId = uuid7();
|
||||||
|
|
||||||
props.editor.chain().focus().setCommentDecoration().run();
|
props.editor.chain().focus().setCommentDecoration().run();
|
||||||
setDraftCommentId(commentId);
|
setDraftCommentId(commentId);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ActionIcon, Card, Image, Modal, Text, useComputedColorScheme } from '@m
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { uploadFile } from '@/features/page/services/page-service.ts';
|
import { uploadFile } from '@/features/page/services/page-service.ts';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { getFileUrl } from '@/lib/config.ts';
|
import { getDrawioUrl, getFileUrl } from '@/lib/config.ts';
|
||||||
import {
|
import {
|
||||||
DrawIoEmbed,
|
DrawIoEmbed,
|
||||||
DrawIoEmbedRef,
|
DrawIoEmbedRef,
|
||||||
@@ -40,7 +40,7 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
let base64data = (reader.result || '') as string;
|
const base64data = (reader.result || '') as string;
|
||||||
setInitialXML(base64data);
|
setInitialXML(base64data);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -87,6 +87,7 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
<DrawIoEmbed
|
<DrawIoEmbed
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
xml={initialXML}
|
xml={initialXML}
|
||||||
|
baseUrl={getDrawioUrl()}
|
||||||
urlParameters={{
|
urlParameters={{
|
||||||
ui: computedColorScheme === 'light' ? 'kennedy' : 'dark',
|
ui: computedColorScheme === 'light' ? 'kennedy' : 'dark',
|
||||||
spin: true,
|
spin: true,
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ export const embedProviders: IEmbedProvider[] = [
|
|||||||
id: 'loom',
|
id: 'loom',
|
||||||
name: 'Loom',
|
name: 'Loom',
|
||||||
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
|
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
|
||||||
getEmbedUrl: (match) => {
|
getEmbedUrl: (match, url) => {
|
||||||
|
if(url.includes("/embed/")){
|
||||||
|
return url;
|
||||||
|
}
|
||||||
return `https://loom.com/embed/${match[1]}`;
|
return `https://loom.com/embed/${match[1]}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -20,6 +23,9 @@ export const embedProviders: IEmbedProvider[] = [
|
|||||||
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
|
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
const path = url.split('airtable.com/');
|
const path = url.split('airtable.com/');
|
||||||
|
if(url.includes("/embed/")){
|
||||||
|
return url;
|
||||||
|
}
|
||||||
return `https://airtable.com/embed/${path[1]}`;
|
return `https://airtable.com/embed/${path[1]}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -43,7 +49,10 @@ export const embedProviders: IEmbedProvider[] = [
|
|||||||
id: 'miro',
|
id: 'miro',
|
||||||
name: 'Miro',
|
name: 'Miro',
|
||||||
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
|
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
|
||||||
getEmbedUrl: (match) => {
|
getEmbedUrl: (match, url) => {
|
||||||
|
if(url.includes("/live-embed/")){
|
||||||
|
return url;
|
||||||
|
}
|
||||||
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
|
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -51,7 +60,10 @@ export const embedProviders: IEmbedProvider[] = [
|
|||||||
id: 'youtube',
|
id: 'youtube',
|
||||||
name: 'YouTube',
|
name: 'YouTube',
|
||||||
regex: /^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
regex: /^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
||||||
getEmbedUrl: (match) => {
|
getEmbedUrl: (match, url) => {
|
||||||
|
if (url.includes("/embed/")){
|
||||||
|
return url;
|
||||||
|
}
|
||||||
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
|
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function MathInlineView(props: NodeViewProps) {
|
|||||||
renderMath(preview || "", mathPreviewContainer.current);
|
renderMath(preview || "", mathPreviewContainer.current);
|
||||||
} else if (preview !== null) {
|
} else if (preview !== null) {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
updateAttributes({ text: preview });
|
updateAttributes({ text: preview.trim() });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [preview, isEditing]);
|
}, [preview, isEditing]);
|
||||||
@@ -97,7 +97,7 @@ export default function MathInlineView(props: NodeViewProps) {
|
|||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
classNames={{ input: classes.textInput }}
|
classNames={{ input: classes.textInput }}
|
||||||
value={preview?.trim() ?? ""}
|
value={preview ?? ""}
|
||||||
placeholder={"E = mc^2"}
|
placeholder={"E = mc^2"}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) {
|
if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
margin: 0 0.1rem;
|
margin: 0 0.1rem;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&.empty {
|
&.empty {
|
||||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
margin: 0 0.1rem;
|
margin: 0 0.1rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
.textInput {
|
.textInput {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ export const getSuggestionItems = ({
|
|||||||
const fuzzyMatch = (query: string, target: string) => {
|
const fuzzyMatch = (query: string, target: string) => {
|
||||||
let queryIndex = 0;
|
let queryIndex = 0;
|
||||||
target = target.toLowerCase();
|
target = target.toLowerCase();
|
||||||
for (let char of target) {
|
for (const char of target) {
|
||||||
if (query[queryIndex] === char) queryIndex++;
|
if (query[queryIndex] === char) queryIndex++;
|
||||||
if (queryIndex === query.length) return true;
|
if (queryIndex === query.length) return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ export function FullEditor({
|
|||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
fluid={fullPageWidth}
|
fluid={fullPageWidth}
|
||||||
{...(fullPageWidth && { mx: 80 })}
|
size={!fullPageWidth && 850}
|
||||||
size={850}
|
|
||||||
className={classes.editor}
|
className={classes.editor}
|
||||||
>
|
>
|
||||||
<MemoizedTitleEditor
|
<MemoizedTitleEditor
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
|||||||
}, [remoteProvider, localProvider]);
|
}, [remoteProvider, localProvider]);
|
||||||
|
|
||||||
const extensions = [
|
const extensions = [
|
||||||
...mainExtensions,
|
... mainExtensions,
|
||||||
...collabExtensions(remoteProvider, currentUser.user),
|
... collabExtensions(remoteProvider, currentUser.user),
|
||||||
];
|
];
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
@@ -184,6 +184,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div onClick={() => editor.commands.focus('end')} style={{ paddingBottom: '20vh' }}></div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EditorSkeleton />
|
<EditorSkeleton />
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: none;
|
background: none;
|
||||||
font-size: inherit;
|
font-size: var(--mantine-font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Code styling */
|
/* Code styling */
|
||||||
@@ -103,12 +103,12 @@
|
|||||||
|
|
||||||
@mixin where-light {
|
@mixin where-light {
|
||||||
background-color: var(--code-bg, var(--mantine-color-gray-1));
|
background-color: var(--code-bg, var(--mantine-color-gray-1));
|
||||||
color: var(--mantine-color-black);
|
color: var(--mantine-color-pink-7);
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin where-dark {
|
@mixin where-dark {
|
||||||
background-color: var(--mantine-color-dark-8);
|
background-color: var(--mantine-color-dark-8);
|
||||||
color: var(--mantine-color-gray-4);
|
color: var(--mantine-color-pink-7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import {
|
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
|
||||||
useUpdatePageMutation,
|
|
||||||
} from "@/features/page/queries/page-query";
|
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||||
@@ -39,7 +37,11 @@ export function TitleEditor({
|
|||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
|
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
|
||||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
|
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
const {
|
||||||
|
data: updatedPageData,
|
||||||
|
mutate: updatePageMutation,
|
||||||
|
status,
|
||||||
|
} = useUpdatePageMutation();
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
@@ -47,7 +49,6 @@ export function TitleEditor({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activePageId, setActivePageId] = useState(pageId);
|
const [activePageId, setActivePageId] = useState(pageId);
|
||||||
|
|
||||||
|
|
||||||
const titleEditor = useEditor({
|
const titleEditor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
Document.extend({
|
Document.extend({
|
||||||
@@ -87,24 +88,29 @@ export function TitleEditor({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debouncedTitle !== null && activePageId === pageId) {
|
if (debouncedTitle !== null && activePageId === pageId) {
|
||||||
updatePageMutation.mutate({
|
updatePageMutation({
|
||||||
pageId: pageId,
|
pageId: pageId,
|
||||||
title: debouncedTitle,
|
title: debouncedTitle,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}, [debouncedTitle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "success" && updatedPageData) {
|
||||||
|
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
|
||||||
|
setTreeData(newTreeData);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "updateOne",
|
operation: "updateOne",
|
||||||
|
spaceId: updatedPageData.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: pageId,
|
id: pageId,
|
||||||
payload: { title: debouncedTitle, slugId: slugId },
|
payload: { title: debouncedTitle, slugId: slugId },
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
|
|
||||||
setTreeData(newTreeData);
|
|
||||||
}
|
}
|
||||||
}, [debouncedTitle]);
|
}, [updatedPageData, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (titleEditor && title !== titleEditor.getText()) {
|
if (titleEditor && title !== titleEditor.getText()) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
.breadcrumbs {
|
.breadcrumbs {
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--mantine-color-default-color);
|
color: var(--mantine-color-default-color);
|
||||||
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mantine-Breadcrumbs-breadcrumb {
|
.mantine-Breadcrumbs-breadcrumb {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
|
|||||||
import {
|
import {
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
IconDots,
|
IconDots,
|
||||||
IconDownload,
|
IconFileExport,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
@@ -24,6 +24,7 @@ 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";
|
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
|
||||||
|
import ExportModal from "@/components/common/export-modal";
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -126,7 +127,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconDownload size={16} />}
|
leftSection={<IconFileExport size={16} />}
|
||||||
onClick={openExportModal}
|
onClick={openExportModal}
|
||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
@@ -154,8 +155,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<PageExportModal
|
<ExportModal
|
||||||
pageId={page.id}
|
type="page"
|
||||||
|
id={page.id}
|
||||||
open={exportOpened}
|
open={exportOpened}
|
||||||
onClose={closeExportModal}
|
onClose={closeExportModal}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Modal, Button, Group, Text, Select } from "@mantine/core";
|
import { Modal, Button, Group, Text, Select, Switch } from "@mantine/core";
|
||||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -57,8 +57,18 @@ export default function PageExportModal({
|
|||||||
<Text size="md">Format</Text>
|
<Text size="md">Format</Text>
|
||||||
</div>
|
</div>
|
||||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||||
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap" pt="md">
|
||||||
|
<div>
|
||||||
|
<Text size="md">Include subpages</Text>
|
||||||
|
</div>
|
||||||
|
<Switch defaultChecked />
|
||||||
|
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
|
||||||
<Group justify="center" mt="md">
|
<Group justify="center" mt="md">
|
||||||
<Button onClick={onClose} variant="default">
|
<Button onClick={onClose} variant="default">
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SimpleGrid cols={2}>
|
<SimpleGrid cols={2}>
|
||||||
<FileButton onChange={handleFileUpload} accept="text/markdown" multiple>
|
<FileButton onChange={handleFileUpload} accept=".md" multiple>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Button
|
<Button
|
||||||
justify="start"
|
justify="start"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconFileDescription,
|
IconFileDescription, IconFileExport,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconPointFilled,
|
IconPointFilled,
|
||||||
@@ -39,7 +39,12 @@ import {
|
|||||||
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||||
import { useClipboard, useElementSize, useMergedRef } from "@mantine/hooks";
|
import {
|
||||||
|
useClipboard,
|
||||||
|
useDisclosure,
|
||||||
|
useElementSize,
|
||||||
|
useMergedRef,
|
||||||
|
} from "@mantine/hooks";
|
||||||
import { dfs } from "react-arborist/dist/module/utils";
|
import { dfs } from "react-arborist/dist/module/utils";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@@ -47,6 +52,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { getAppUrl } from "@/lib/config.ts";
|
import { getAppUrl } from "@/lib/config.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||||
|
import ExportModal from "@/components/common/export-modal";
|
||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -133,13 +139,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
flatTreeItems = [
|
flatTreeItems = [
|
||||||
...flatTreeItems,
|
...flatTreeItems,
|
||||||
...children.filter(
|
...children.filter(
|
||||||
(child) => !flatTreeItems.some((item) => item.id === child.id),
|
(child) => !flatTreeItems.some((item) => item.id === child.id)
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchPromises = ancestors.map((ancestor) =>
|
const fetchPromises = ancestors.map((ancestor) =>
|
||||||
fetchAndUpdateChildren(ancestor),
|
fetchAndUpdateChildren(ancestor)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for all fetch operations to complete
|
// Wait for all fetch operations to complete
|
||||||
@@ -153,7 +159,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
const updatedTree = appendNodeChildren(
|
const updatedTree = appendNodeChildren(
|
||||||
data,
|
data,
|
||||||
rootChild.id,
|
rootChild.id,
|
||||||
rootChild.children,
|
rootChild.children
|
||||||
);
|
);
|
||||||
setData(updatedTree);
|
setData(updatedTree);
|
||||||
|
|
||||||
@@ -191,13 +197,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
<div ref={mergedRef} className={classes.treeContainer}>
|
<div ref={mergedRef} className={classes.treeContainer}>
|
||||||
{rootElement.current && (
|
{rootElement.current && (
|
||||||
<Tree
|
<Tree
|
||||||
data={data}
|
data={data.filter((node) => node?.spaceId === spaceId)}
|
||||||
disableDrag={readOnly}
|
disableDrag={readOnly}
|
||||||
disableDrop={readOnly}
|
disableDrop={readOnly}
|
||||||
disableEdit={readOnly}
|
disableEdit={readOnly}
|
||||||
{...controllers}
|
{...controllers}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={rootElement.current.clientHeight}
|
||||||
ref={treeApiRef}
|
ref={treeApiRef}
|
||||||
openByDefault={false}
|
openByDefault={false}
|
||||||
disableMultiSelection={true}
|
disableMultiSelection={true}
|
||||||
@@ -248,7 +254,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
const updatedTreeData = appendNodeChildren(
|
const updatedTreeData = appendNodeChildren(
|
||||||
treeData,
|
treeData,
|
||||||
node.data.id,
|
node.data.id,
|
||||||
childrenTree,
|
childrenTree
|
||||||
);
|
);
|
||||||
|
|
||||||
setTreeData(updatedTreeData);
|
setTreeData(updatedTreeData);
|
||||||
@@ -279,6 +285,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "updateOne",
|
operation: "updateOne",
|
||||||
|
spaceId: node.data.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: node.id,
|
id: node.id,
|
||||||
payload: { icon: emoji.native },
|
payload: { icon: emoji.native },
|
||||||
@@ -293,6 +300,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "updateOne",
|
operation: "updateOne",
|
||||||
|
spaceId: node.data.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: node.id,
|
id: node.id,
|
||||||
payload: { icon: null },
|
payload: { icon: null },
|
||||||
@@ -400,6 +408,8 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { openDeleteModal } = useDeletePageModal();
|
const { openDeleteModal } = useDeletePageModal();
|
||||||
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
@@ -409,56 +419,76 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu shadow="md" width={200}>
|
<>
|
||||||
<Menu.Target>
|
<Menu shadow="md" width={200}>
|
||||||
<ActionIcon
|
<Menu.Target>
|
||||||
variant="transparent"
|
<ActionIcon
|
||||||
c="gray"
|
variant="transparent"
|
||||||
onClick={(e) => {
|
c="gray"
|
||||||
e.preventDefault();
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.preventDefault();
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
}}
|
||||||
<IconDotsVertical
|
>
|
||||||
style={{ width: rem(20), height: rem(20) }}
|
<IconDotsVertical
|
||||||
stroke={2}
|
style={{ width: rem(20), height: rem(20) }}
|
||||||
/>
|
stroke={2}
|
||||||
</ActionIcon>
|
/>
|
||||||
</Menu.Target>
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
leftSection={<IconLink size={16} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleCopyLink();
|
handleCopyLink();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy link
|
Copy link
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
{!(treeApi.props.disableEdit as boolean) && (
|
<Menu.Item
|
||||||
<>
|
leftSection={<IconFileExport size={16} />}
|
||||||
<Menu.Divider />
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openExportModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export page
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
{!(treeApi.props.disableEdit as boolean) && (
|
||||||
c="red"
|
<>
|
||||||
leftSection={
|
<Menu.Divider />
|
||||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
|
||||||
}
|
<Menu.Item
|
||||||
onClick={(e) => {
|
c="red"
|
||||||
e.preventDefault();
|
leftSection={
|
||||||
e.stopPropagation();
|
<IconTrash size={16} />
|
||||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
}
|
||||||
}}
|
onClick={(e) => {
|
||||||
>
|
e.preventDefault();
|
||||||
Delete
|
e.stopPropagation();
|
||||||
</Menu.Item>
|
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||||
</>
|
}}
|
||||||
)}
|
>
|
||||||
</Menu.Dropdown>
|
Delete
|
||||||
</Menu>
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<ExportModal
|
||||||
|
type="page"
|
||||||
|
id={node.id}
|
||||||
|
open={exportOpened}
|
||||||
|
onClose={closeExportModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,18 +75,19 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "addTreeNode",
|
operation: "addTreeNode",
|
||||||
|
spaceId: spaceId,
|
||||||
payload: {
|
payload: {
|
||||||
parentId,
|
parentId,
|
||||||
index,
|
index,
|
||||||
data
|
data,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
const pageUrl = buildPageUrl(
|
const pageUrl = buildPageUrl(
|
||||||
spaceSlug,
|
spaceSlug,
|
||||||
createdPage.slugId,
|
createdPage.slugId,
|
||||||
createdPage.title,
|
createdPage.title
|
||||||
);
|
);
|
||||||
navigate(pageUrl);
|
navigate(pageUrl);
|
||||||
return data;
|
return data;
|
||||||
@@ -156,18 +157,16 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
// check if the previous still has children
|
// check if the previous still has children
|
||||||
// if no children left, change 'hasChildren' to false, to make the page toggle arrows work properly
|
// if no children left, change 'hasChildren' to false, to make the page toggle arrows work properly
|
||||||
const childrenCount = previousParent.children.filter(
|
const childrenCount = previousParent.children.filter(
|
||||||
(child) => child.id !== draggedNodeId,
|
(child) => child.id !== draggedNodeId
|
||||||
).length;
|
).length;
|
||||||
if (childrenCount === 0) {
|
if (childrenCount === 0) {
|
||||||
tree.update({
|
tree.update({
|
||||||
id: previousParent.id,
|
id: previousParent.id,
|
||||||
changes: { ... previousParent.data, hasChildren: false } as any,
|
changes: { ...previousParent.data, hasChildren: false } as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log()
|
|
||||||
|
|
||||||
setData(tree.data);
|
setData(tree.data);
|
||||||
|
|
||||||
const payload: IMovePage = {
|
const payload: IMovePage = {
|
||||||
@@ -182,7 +181,13 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "moveTreeNode",
|
operation: "moveTreeNode",
|
||||||
payload: { id: draggedNodeId, parentId: args.parentId, index: args.index, position: newPosition },
|
spaceId: spaceId,
|
||||||
|
payload: {
|
||||||
|
id: draggedNodeId,
|
||||||
|
parentId: args.parentId,
|
||||||
|
index: args.index,
|
||||||
|
position: newPosition,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -214,17 +219,17 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
setData(tree.data);
|
setData(tree.data);
|
||||||
|
|
||||||
// navigate only if the current url is same as the deleted page
|
// navigate only if the current url is same as the deleted page
|
||||||
if (pageSlug && node.data.slugId === pageSlug.split('-')[1]) {
|
if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) {
|
||||||
navigate(getSpaceUrl(spaceSlug));
|
navigate(getSpaceUrl(spaceSlug));
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "deleteTreeNode",
|
operation: "deleteTreeNode",
|
||||||
payload: { node: node.data }
|
spaceId: spaceId,
|
||||||
|
payload: { node: node.data },
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete page:", error);
|
console.error("Failed to delete page:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.treeContainer {
|
.treeContainer {
|
||||||
display: flex;
|
height: 100%;
|
||||||
height: 68vh;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
> div, > div > .tree {
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.node {
|
.node {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface IPageInput {
|
|||||||
export interface IExportPageParams {
|
export interface IExportPageParams {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
format: ExportFormat;
|
format: ExportFormat;
|
||||||
|
includeChildren?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ExportFormat {
|
export enum ExportFormat {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function SpaceSettingsModal({
|
|||||||
const {data: space, isLoading} = useSpaceQuery(spaceId);
|
const {data: space, isLoading} = useSpaceQuery(spaceId);
|
||||||
|
|
||||||
const spaceRules = space?.membership?.permissions;
|
const spaceRules = space?.membership?.permissions;
|
||||||
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
|
const spaceAbility = useSpaceAbility(spaceRules);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
@@ -18,6 +19,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sectionPages {
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow-y: hidden;
|
||||||
|
|
||||||
|
.pages {
|
||||||
|
height: 100%;
|
||||||
|
padding-bottom: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.menuItems {
|
.menuItems {
|
||||||
padding-left: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
|
padding-left: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
|
||||||
padding-right: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
|
padding-right: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
|
||||||
|
|||||||
@@ -5,36 +5,38 @@ import {
|
|||||||
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,
|
IconArrowDown,
|
||||||
IconDots,
|
IconDots,
|
||||||
|
IconFileExport,
|
||||||
IconHome,
|
IconHome,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import classes from './space-sidebar.module.css';
|
import classes from "./space-sidebar.module.css";
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from "react";
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from "jotai";
|
||||||
import { SearchSpotlight } from '@/features/search/search-spotlight.tsx';
|
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
|
||||||
import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom.ts';
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
import { Link, useLocation, useParams } from "react-router-dom";
|
||||||
import clsx from 'clsx';
|
import clsx from "clsx";
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx';
|
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||||
import { useGetSpaceBySlugQuery } from '@/features/space/queries/space-query.ts';
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { getSpaceUrl } from '@/lib/config.ts';
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
import SpaceTree from '@/features/page/tree/components/space-tree.tsx';
|
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
|
||||||
import { useSpaceAbility } from '@/features/space/permissions/use-space-ability.ts';
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
import {
|
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';
|
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
|
||||||
import { SwitchSpace } from './switch-space';
|
import { SwitchSpace } from "./switch-space";
|
||||||
|
import ExportModal from "@/components/common/export-modal";
|
||||||
|
|
||||||
export function SpaceSidebar() {
|
export function SpaceSidebar() {
|
||||||
const [tree] = useAtom(treeApiAtom);
|
const [tree] = useAtom(treeApiAtom);
|
||||||
@@ -45,14 +47,14 @@ export function SpaceSidebar() {
|
|||||||
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
const spaceRules = space?.membership?.permissions;
|
const spaceRules = space?.membership?.permissions;
|
||||||
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
|
const spaceAbility = useSpaceAbility(spaceRules);
|
||||||
|
|
||||||
if (!space) {
|
if (!space) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreatePage() {
|
function handleCreatePage() {
|
||||||
tree?.create({ parentId: null, type: 'internal', index: 0 });
|
tree?.create({ parentId: null, type: "internal", index: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,7 +63,7 @@ export function SpaceSidebar() {
|
|||||||
<div
|
<div
|
||||||
className={classes.section}
|
className={classes.section}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: "none",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
marginBottom: 3,
|
marginBottom: 3,
|
||||||
}}
|
}}
|
||||||
@@ -78,7 +80,7 @@ export function SpaceSidebar() {
|
|||||||
classes.menu,
|
classes.menu,
|
||||||
location.pathname.toLowerCase() === getSpaceUrl(spaceSlug)
|
location.pathname.toLowerCase() === getSpaceUrl(spaceSlug)
|
||||||
? classes.activeButton
|
? classes.activeButton
|
||||||
: ''
|
: ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={classes.menuItemInner}>
|
<div className={classes.menuItemInner}>
|
||||||
@@ -134,7 +136,7 @@ export function SpaceSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classes.section}>
|
<div className={clsx(classes.section, classes.sectionPages)}>
|
||||||
<Group className={classes.pagesHeader} justify="space-between">
|
<Group className={classes.pagesHeader} justify="space-between">
|
||||||
<Text size="xs" fw={500} c="dimmed">
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
Pages
|
Pages
|
||||||
@@ -191,6 +193,8 @@ interface SpaceMenuProps {
|
|||||||
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -215,6 +219,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
|||||||
Import pages
|
Import pages
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
onClick={openExportModal}
|
||||||
|
leftSection={<IconFileExport size={16} />}
|
||||||
|
>
|
||||||
|
Export space
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -231,6 +242,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
|||||||
open={importOpened}
|
open={importOpened}
|
||||||
onClose={closeImportModal}
|
onClose={closeImportModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ExportModal
|
||||||
|
type="space"
|
||||||
|
id={spaceId}
|
||||||
|
open={exportOpened}
|
||||||
|
onClose={closeExportModal}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ interface SwitchSpaceProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
||||||
const [opened, { close, open, toggle }] = useDisclosure(false);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
const handleSelect = (value: string) => {
|
||||||
@@ -28,7 +27,6 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
|||||||
position="bottom"
|
position="bottom"
|
||||||
withArrow
|
withArrow
|
||||||
shadow="md"
|
shadow="md"
|
||||||
opened={opened}
|
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Button
|
<Button
|
||||||
@@ -37,7 +35,6 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
|||||||
justify="space-between"
|
justify="space-between"
|
||||||
rightSection={<IconChevronDown size={18} />}
|
rightSection={<IconChevronDown size={18} />}
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={toggle}
|
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
size={20}
|
size={20}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
|
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
|
||||||
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
|
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
|
||||||
import { Divider, Group, Text } from '@mantine/core';
|
import { Button, Divider, Group, Text } from '@mantine/core';
|
||||||
import DeleteSpaceModal from './delete-space-modal';
|
import DeleteSpaceModal from './delete-space-modal';
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import ExportModal from "@/components/common/export-modal.tsx";
|
||||||
|
|
||||||
interface SpaceDetailsProps {
|
interface SpaceDetailsProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -10,6 +12,8 @@ interface SpaceDetailsProps {
|
|||||||
}
|
}
|
||||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||||
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
||||||
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -22,6 +26,22 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
|||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
|
<Divider my="lg" />
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
|
<div>
|
||||||
|
<Text size="md">Export space</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Export all pages and attachments in this space
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={openExportModal}>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
@@ -34,6 +54,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
|||||||
|
|
||||||
<DeleteSpaceModal space={space} />
|
<DeleteSpaceModal space={space} />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<ExportModal
|
||||||
|
type="space"
|
||||||
|
id={space.id}
|
||||||
|
open={exportOpened}
|
||||||
|
onClose={closeExportModal}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,56 +1,72 @@
|
|||||||
import api from '@/lib/api-client';
|
import api from "@/lib/api-client";
|
||||||
import {
|
import {
|
||||||
IAddSpaceMember,
|
IAddSpaceMember,
|
||||||
IChangeSpaceMemberRole,
|
IChangeSpaceMemberRole,
|
||||||
|
IExportSpaceParams,
|
||||||
IRemoveSpaceMember,
|
IRemoveSpaceMember,
|
||||||
ISpace,
|
ISpace,
|
||||||
} from "@/features/space/types/space.types";
|
} from "@/features/space/types/space.types";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
|
import { saveAs } from "file-saver";
|
||||||
|
|
||||||
export async function getSpaces(params?: QueryParams): Promise<IPagination<ISpace>> {
|
export async function getSpaces(
|
||||||
|
params?: QueryParams
|
||||||
|
): Promise<IPagination<ISpace>> {
|
||||||
const req = await api.post("/spaces", params);
|
const req = await api.post("/spaces", params);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSpaceById(spaceId: string): Promise<ISpace> {
|
export async function getSpaceById(spaceId: string): Promise<ISpace> {
|
||||||
const req = await api.post<ISpace>('/spaces/info', { spaceId });
|
const req = await api.post<ISpace>("/spaces/info", { spaceId });
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSpace(data: Partial<ISpace>): Promise<ISpace> {
|
export async function createSpace(data: Partial<ISpace>): Promise<ISpace> {
|
||||||
const req = await api.post<ISpace>('/spaces/create', data);
|
const req = await api.post<ISpace>("/spaces/create", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> {
|
export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> {
|
||||||
const req = await api.post<ISpace>('/spaces/update', data);
|
const req = await api.post<ISpace>("/spaces/update", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSpace(spaceId: string): Promise<void> {
|
export async function deleteSpace(spaceId: string): Promise<void> {
|
||||||
await api.post<void>('/spaces/delete', { spaceId });
|
await api.post<void>("/spaces/delete", { spaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSpaceMembers(
|
export async function getSpaceMembers(
|
||||||
spaceId: string
|
spaceId: string
|
||||||
): Promise<IPagination<IUser>> {
|
): Promise<IPagination<IUser>> {
|
||||||
const req = await api.post<any>('/spaces/members', { spaceId });
|
const req = await api.post<any>("/spaces/members", { spaceId });
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
|
export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
|
||||||
await api.post('/spaces/members/add', data);
|
await api.post("/spaces/members/add", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeSpaceMember(
|
export async function removeSpaceMember(
|
||||||
data: IRemoveSpaceMember
|
data: IRemoveSpaceMember
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await api.post('/spaces/members/remove', data);
|
await api.post("/spaces/members/remove", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function changeMemberRole(
|
export async function changeMemberRole(
|
||||||
data: IChangeSpaceMemberRole
|
data: IChangeSpaceMemberRole
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await api.post('/spaces/members/change-role', data);
|
await api.post("/spaces/members/change-role", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSpace(data: IExportSpaceParams): Promise<void> {
|
||||||
|
const req = await api.post("/spaces/export", data, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = req?.headers["content-disposition"]
|
||||||
|
.split("filename=")[1]
|
||||||
|
.replace(/"/g, "");
|
||||||
|
|
||||||
|
saveAs(req.data, decodeURIComponent(fileName));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||||
|
|
||||||
export interface ISpace {
|
export interface ISpace {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -68,3 +69,9 @@ export interface SpaceGroupInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ISpaceMember = { role: string } & (SpaceUserInfo | SpaceGroupInfo);
|
export type ISpaceMember = { role: string } & (SpaceUserInfo | SpaceGroupInfo);
|
||||||
|
|
||||||
|
export interface IExportSpaceParams {
|
||||||
|
spaceId: string;
|
||||||
|
format: ExportFormat;
|
||||||
|
includeAttachments?: boolean;
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|||||||
|
|
||||||
export type InvalidateEvent = {
|
export type InvalidateEvent = {
|
||||||
operation: "invalidate";
|
operation: "invalidate";
|
||||||
|
spaceId: string;
|
||||||
entity: Array<string>;
|
entity: Array<string>;
|
||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateEvent = {
|
export type UpdateEvent = {
|
||||||
operation: "updateOne";
|
operation: "updateOne";
|
||||||
|
spaceId: string;
|
||||||
entity: Array<string>;
|
entity: Array<string>;
|
||||||
id: string;
|
id: string;
|
||||||
payload: Partial<any>;
|
payload: Partial<any>;
|
||||||
@@ -15,6 +17,7 @@ export type UpdateEvent = {
|
|||||||
|
|
||||||
export type DeleteEvent = {
|
export type DeleteEvent = {
|
||||||
operation: "deleteOne";
|
operation: "deleteOne";
|
||||||
|
spaceId: string;
|
||||||
entity: Array<string>;
|
entity: Array<string>;
|
||||||
id: string;
|
id: string;
|
||||||
payload?: Partial<any>;
|
payload?: Partial<any>;
|
||||||
@@ -22,6 +25,7 @@ export type DeleteEvent = {
|
|||||||
|
|
||||||
export type AddTreeNodeEvent = {
|
export type AddTreeNodeEvent = {
|
||||||
operation: "addTreeNode";
|
operation: "addTreeNode";
|
||||||
|
spaceId: string;
|
||||||
payload: {
|
payload: {
|
||||||
parentId: string;
|
parentId: string;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -31,6 +35,7 @@ export type AddTreeNodeEvent = {
|
|||||||
|
|
||||||
export type MoveTreeNodeEvent = {
|
export type MoveTreeNodeEvent = {
|
||||||
operation: "moveTreeNode";
|
operation: "moveTreeNode";
|
||||||
|
spaceId: string;
|
||||||
payload: {
|
payload: {
|
||||||
id: string;
|
id: string;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
@@ -41,6 +46,7 @@ export type MoveTreeNodeEvent = {
|
|||||||
|
|
||||||
export type DeleteTreeNodeEvent = {
|
export type DeleteTreeNodeEvent = {
|
||||||
operation: "deleteTreeNode";
|
operation: "deleteTreeNode";
|
||||||
|
spaceId: string;
|
||||||
payload: {
|
payload: {
|
||||||
node: SpaceTreeNode
|
node: SpaceTreeNode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,30 +46,34 @@ export const useTreeSocket = () => {
|
|||||||
break;
|
break;
|
||||||
case 'moveTreeNode':
|
case 'moveTreeNode':
|
||||||
// move node
|
// move node
|
||||||
treeApi.move({
|
if (treeApi.find(event.payload.id)) {
|
||||||
id: event.payload.id,
|
treeApi.move({
|
||||||
parentId: event.payload.parentId,
|
id: event.payload.id,
|
||||||
index: event.payload.index
|
parentId: event.payload.parentId,
|
||||||
});
|
index: event.payload.index
|
||||||
|
});
|
||||||
|
|
||||||
// update node position
|
// update node position
|
||||||
treeApi.update({
|
treeApi.update({
|
||||||
id: event.payload.id,
|
id: event.payload.id,
|
||||||
changes: {
|
changes: {
|
||||||
position: event.payload.position,
|
position: event.payload.position,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setTreeData(treeApi.data);
|
setTreeData(treeApi.data);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "deleteTreeNode":
|
case "deleteTreeNode":
|
||||||
treeApi.drop({ id: event.payload.node.id });
|
if (treeApi.find(event.payload.node.id)){
|
||||||
setTreeData(treeApi.data);
|
treeApi.drop({ id: event.payload.node.id });
|
||||||
|
setTreeData(treeApi.data);
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['pages', event.payload.node.slugId].filter(Boolean),
|
queryKey: ['pages', event.payload.node.slugId].filter(Boolean),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ export function useGetInvitationQuery(
|
|||||||
invitationId: string,
|
invitationId: string,
|
||||||
): UseQueryResult<any, Error> {
|
): UseQueryResult<any, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
|
||||||
queryKey: ["invitations", invitationId],
|
queryKey: ["invitations", invitationId],
|
||||||
queryFn: () => getInvitationById({ invitationId }),
|
queryFn: () => getInvitationById({ invitationId }),
|
||||||
enabled: !!invitationId,
|
enabled: !!invitationId,
|
||||||
|
|||||||
@@ -26,14 +26,18 @@ api.interceptors.request.use(
|
|||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
// we need the response headers
|
// we need the response headers for these endpoints
|
||||||
if (response.request.responseURL.includes("/api/pages/export")) {
|
const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"];
|
||||||
return response;
|
if (response.request.responseURL) {
|
||||||
|
const path = new URL(response.request.responseURL)?.pathname;
|
||||||
|
if (path && exemptEndpoints.includes(path)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -72,7 +76,7 @@ api.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function redirectToLogin() {
|
function redirectToLogin() {
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ export function getFileUrl(src: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFileUploadSizeLimit() {
|
export function getFileUploadSizeLimit() {
|
||||||
const limit = window.CONFIG?.FILE_UPLOAD_SIZE_LIMIT || process?.env.FILE_UPLOAD_SIZE_LIMIT || '50mb';
|
const limit =getConfigValue("FILE_UPLOAD_SIZE_LIMIT", "50mb");
|
||||||
return bytes(limit);
|
return bytes(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDrawioUrl() {
|
||||||
|
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigValue(key: string, defaultValue: string = undefined) {
|
||||||
|
return window.CONFIG?.[key] || process?.env?.[key] || defaultValue;
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,9 @@ import { atom } from "jotai";
|
|||||||
|
|
||||||
export function atomWithWebStorage<Value>(key: string, initialValue: Value, storage = localStorage) {
|
export function atomWithWebStorage<Value>(key: string, initialValue: Value, storage = localStorage) {
|
||||||
const storedValue = localStorage.getItem(key);
|
const storedValue = localStorage.getItem(key);
|
||||||
const isString = typeof initialValue === "string";
|
const isStringOrInt = typeof initialValue === "string" || typeof initialValue === "number";
|
||||||
|
|
||||||
const storageValue = storedValue ? isString ? storedValue : storedValue === "true" : undefined;
|
const storageValue = storedValue ? isStringOrInt ? storedValue : storedValue === "true" : undefined;
|
||||||
|
|
||||||
const baseAtom = atom(storageValue ?? initialValue);
|
const baseAtom = atom(storageValue ?? initialValue);
|
||||||
return atom(
|
return atom(
|
||||||
|
|||||||
@@ -74,4 +74,4 @@ export function decodeBase64ToSvgString(base64Data: string): string {
|
|||||||
|
|
||||||
export function capitalizeFirstChar(string: string) {
|
export function capitalizeFirstChar(string: string) {
|
||||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function Page() {
|
|||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const spaceRules = space?.membership?.permissions;
|
const spaceRules = space?.membership?.permissions;
|
||||||
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
|
const spaceAbility = useSpaceAbility(spaceRules);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <></>;
|
return <></>;
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import * as path from "path";
|
|||||||
export const envPath = path.resolve(process.cwd(), "..", "..");
|
export const envPath = path.resolve(process.cwd(), "..", "..");
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const { APP_URL, FILE_UPLOAD_SIZE_LIMIT } = loadEnv(mode, envPath, "");
|
const { APP_URL, FILE_UPLOAD_SIZE_LIMIT, DRAWIO_URL } = loadEnv(mode, envPath, "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
define: {
|
define: {
|
||||||
"process.env": {
|
"process.env": {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
|
DRAWIO_URL
|
||||||
},
|
},
|
||||||
'APP_VERSION': JSON.stringify(process.env.npm_package_version),
|
'APP_VERSION': JSON.stringify(process.env.npm_package_version),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
parserOptions: {
|
|
||||||
project: 'tsconfig.json',
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
sourceType: 'module',
|
|
||||||
},
|
|
||||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
|
||||||
extends: [
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:prettier/recommended',
|
|
||||||
],
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true,
|
|
||||||
jest: true,
|
|
||||||
},
|
|
||||||
ignorePatterns: ['.eslintrc.js'],
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/interface-name-prefix': 'off',
|
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
eslintConfigPrettier,
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.node, ...globals.jest },
|
||||||
|
sourceType: 'module',
|
||||||
|
parser: tseslint.parser,
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
|
'@typescript-eslint/no-empty-object-type': 'off',
|
||||||
|
'prefer-rest-params': 'off',
|
||||||
|
'no-useless-catch': 'off',
|
||||||
|
'no-useless-escape': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
+37
-37
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.5.0",
|
"version": "0.6.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -28,43 +28,43 @@
|
|||||||
"test:e2e": "jest --config test/jest-e2e.json"
|
"test:e2e": "jest --config test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.637.0",
|
"@aws-sdk/client-s3": "^3.701.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.637.0",
|
"@aws-sdk/s3-request-presigner": "^3.701.0",
|
||||||
"@casl/ability": "^6.7.1",
|
"@casl/ability": "^6.7.2",
|
||||||
"@fastify/cookie": "^9.4.0",
|
"@fastify/cookie": "^9.4.0",
|
||||||
"@fastify/multipart": "^8.3.0",
|
"@fastify/multipart": "^8.3.0",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"@nestjs/bullmq": "^10.2.1",
|
"@nestjs/bullmq": "^10.2.2",
|
||||||
"@nestjs/common": "^10.4.1",
|
"@nestjs/common": "^10.4.9",
|
||||||
"@nestjs/config": "^3.2.3",
|
"@nestjs/config": "^3.3.0",
|
||||||
"@nestjs/core": "^10.4.1",
|
"@nestjs/core": "^10.4.9",
|
||||||
"@nestjs/event-emitter": "^2.0.4",
|
"@nestjs/event-emitter": "^2.1.1",
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/mapped-types": "^2.0.5",
|
"@nestjs/mapped-types": "^2.0.6",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-fastify": "^10.4.1",
|
"@nestjs/platform-fastify": "^10.4.9",
|
||||||
"@nestjs/platform-socket.io": "^10.4.1",
|
"@nestjs/platform-socket.io": "^10.4.9",
|
||||||
"@nestjs/terminus": "^10.2.3",
|
"@nestjs/terminus": "^10.2.3",
|
||||||
"@nestjs/websockets": "^10.4.1",
|
"@nestjs/websockets": "^10.4.9",
|
||||||
"@react-email/components": "0.0.24",
|
"@react-email/components": "0.0.28",
|
||||||
"@react-email/render": "^1.0.1",
|
"@react-email/render": "^1.0.2",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.12.12",
|
"bullmq": "^5.29.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"fix-esm": "^1.0.1",
|
"fix-esm": "^1.0.1",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
"happy-dom": "^15.7.3",
|
"happy-dom": "^15.11.6",
|
||||||
"kysely": "^0.27.4",
|
"kysely": "^0.27.4",
|
||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
"marked": "^13.0.3",
|
"marked": "^13.0.3",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.9",
|
||||||
"nestjs-kysely": "^1.0.0",
|
"nestjs-kysely": "^1.0.0",
|
||||||
"nodemailer": "^6.9.14",
|
"nodemailer": "^6.9.16",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.13.1",
|
||||||
"pg-tsquery": "^8.4.2",
|
"pg-tsquery": "^8.4.2",
|
||||||
"postmark": "^4.0.5",
|
"postmark": "^4.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -72,40 +72,40 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sanitize-filename-ts": "^1.0.2",
|
"sanitize-filename-ts": "^1.0.2",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.8.1",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.4.5",
|
"@eslint/js": "^9.16.0",
|
||||||
"@nestjs/schematics": "^10.1.4",
|
"@nestjs/cli": "^10.4.8",
|
||||||
"@nestjs/testing": "^10.4.1",
|
"@nestjs/schematics": "^10.2.3",
|
||||||
|
"@nestjs/testing": "^10.4.9",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/debounce": "^1.2.4",
|
"@types/debounce": "^1.2.4",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/node": "^22.5.2",
|
"@types/node": "^22.10.0",
|
||||||
"@types/nodemailer": "^6.4.15",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pg": "^8.11.8",
|
"@types/pg": "^8.11.10",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.13",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
"eslint": "^9.15.0",
|
||||||
"@typescript-eslint/parser": "^8.3.0",
|
|
||||||
"eslint": "^9.9.1",
|
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"globals": "^15.13.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"kysely-codegen": "^0.16.3",
|
"kysely-codegen": "^0.17.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.4.1",
|
||||||
"react-email": "^3.0.1",
|
"react-email": "^3.0.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.7.2",
|
||||||
|
"typescript-eslint": "^8.17.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export class CollaborationGateway {
|
|||||||
port: this.redisConfig.port,
|
port: this.redisConfig.port,
|
||||||
options: {
|
options: {
|
||||||
password: this.redisConfig.password,
|
password: this.redisConfig.password,
|
||||||
|
db: this.redisConfig.db,
|
||||||
retryStrategy: createRetryStrategy(),
|
retryStrategy: createRetryStrategy(),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import {StarterKit} from '@tiptap/starter-kit';
|
import { StarterKit } from '@tiptap/starter-kit';
|
||||||
import {TextAlign} from '@tiptap/extension-text-align';
|
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 {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 {Youtube} from '@tiptap/extension-youtube';
|
import { Youtube } from '@tiptap/extension-youtube';
|
||||||
import Table from '@tiptap/extension-table';
|
import Table from '@tiptap/extension-table';
|
||||||
import TableHeader from '@tiptap/extension-table-header';
|
import TableHeader from '@tiptap/extension-table-header';
|
||||||
import {
|
import {
|
||||||
@@ -30,14 +30,15 @@ import {
|
|||||||
Attachment,
|
Attachment,
|
||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed
|
Embed,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import {generateText, JSONContent} from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import {generateHTML} from '../common/helpers/prosemirror/html';
|
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||||
// @tiptap/html library works best for generating prosemirror json state but not 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/5352
|
||||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||||
import {generateJSON} from '@tiptap/html';
|
import { generateJSON } from '@tiptap/html';
|
||||||
|
import { Node } from '@tiptap/pm/model';
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
@@ -73,7 +74,7 @@ export const tiptapExtensions = [
|
|||||||
CustomCodeBlock,
|
CustomCodeBlock,
|
||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed
|
Embed,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: any) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
@@ -88,6 +89,10 @@ export function jsonToText(tiptapJson: JSONContent) {
|
|||||||
return generateText(tiptapJson, tiptapExtensions);
|
return generateText(tiptapJson, tiptapExtensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function jsonToNode(tiptapJson: JSONContent) {
|
||||||
|
return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPageId(documentName: string) {
|
export function getPageId(documentName: string) {
|
||||||
return documentName.split('.')[1];
|
return documentName.split('.')[1];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { customAlphabet } = require('fix-esm').require('nanoid');
|
const { customAlphabet } = require('fix-esm').require('nanoid');
|
||||||
|
|
||||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
|
|||||||
@@ -18,15 +18,25 @@ export async function comparePasswordHash(
|
|||||||
export type RedisConfig = {
|
export type RedisConfig = {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
db: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseRedisUrl(redisUrl: string): RedisConfig {
|
export function parseRedisUrl(redisUrl: string): RedisConfig {
|
||||||
// format - redis[s]://[[username][:password]@][host][:port][/db-number]
|
// format - redis[s]://[[username][:password]@][host][:port][/db-number]
|
||||||
const { hostname, port, password } = new URL(redisUrl);
|
const { hostname, port, password, pathname } = new URL(redisUrl);
|
||||||
const portInt = parseInt(port, 10);
|
const portInt = parseInt(port, 10);
|
||||||
|
|
||||||
return { host: hostname, port: portInt, password };
|
let db: number = 0;
|
||||||
|
// extract db value if present
|
||||||
|
if (pathname.length > 1) {
|
||||||
|
const value = pathname.slice(1);
|
||||||
|
if (!isNaN(parseInt(value))){
|
||||||
|
db = parseInt(value, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { host: hostname, port: portInt, password, db };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRetryStrategy() {
|
export function createRetryStrategy() {
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export class AttachmentController {
|
|||||||
const fileStream = await this.storageService.read(attachment.filePath);
|
const fileStream = await this.storageService.read(attachment.filePath);
|
||||||
res.headers({
|
res.headers({
|
||||||
'Content-Type': attachment.mimeType,
|
'Content-Type': attachment.mimeType,
|
||||||
'Cache-Control': 'public, max-age=3600',
|
'Cache-Control': 'private, max-age=3600',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
||||||
@@ -299,7 +299,7 @@ export class AttachmentController {
|
|||||||
const fileStream = await this.storageService.read(filePath);
|
const fileStream = await this.storageService.read(filePath);
|
||||||
res.headers({
|
res.headers({
|
||||||
'Content-Type': getMimeType(filePath),
|
'Content-Type': getMimeType(filePath),
|
||||||
'Cache-Control': 'public, max-age=86400',
|
'Cache-Control': 'private, max-age=86400',
|
||||||
});
|
});
|
||||||
return res.send(fileStream);
|
return res.send(fileStream);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class AttachmentService {
|
|||||||
// passing attachmentId to allow for updating diagrams
|
// passing attachmentId to allow for updating diagrams
|
||||||
// instead of creating new files for each save
|
// instead of creating new files for each save
|
||||||
if (opts?.attachmentId) {
|
if (opts?.attachmentId) {
|
||||||
let existingAttachment = await this.attachmentRepo.findById(
|
const existingAttachment = await this.attachmentRepo.findById(
|
||||||
opts.attachmentId,
|
opts.attachmentId,
|
||||||
);
|
);
|
||||||
if (!existingAttachment) {
|
if (!existingAttachment) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
@@ -13,6 +14,8 @@ import { FastifyRequest } from 'fastify';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
private logger = new Logger('JwtStrategy');
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private userRepo: UserRepo,
|
private userRepo: UserRepo,
|
||||||
private workspaceRepo: WorkspaceRepo,
|
private workspaceRepo: WorkspaceRepo,
|
||||||
@@ -24,7 +27,9 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
accessToken = JSON.parse(req.cookies?.authTokens)?.accessToken;
|
accessToken = JSON.parse(req.cookies?.authTokens)?.accessToken;
|
||||||
} catch {}
|
} catch {
|
||||||
|
this.logger.debug('Failed to parse access token');
|
||||||
|
}
|
||||||
|
|
||||||
return accessToken || this.extractTokenFromHeader(req);
|
return accessToken || this.extractTokenFromHeader(req);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { InjectKysely } from 'nestjs-kysely';
|
|||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const tsquery = require('pg-tsquery')();
|
const tsquery = require('pg-tsquery')();
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ export async function executeWithPagination<O, DB, TB extends keyof DB>(
|
|||||||
.select((eb) => eb.ref(deferredJoinPrimaryKey).as('primaryKey'))
|
.select((eb) => eb.ref(deferredJoinPrimaryKey).as('primaryKey'))
|
||||||
.execute()
|
.execute()
|
||||||
// @ts-expect-error TODO: Fix the type here later
|
// @ts-expect-error TODO: Fix the type here later
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
||||||
.then((rows) => rows.map((row) => row.primaryKey));
|
.then((rows) => rows.map((row) => row.primaryKey));
|
||||||
|
|
||||||
qb = qb
|
qb = qb
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
primaryKeys.length > 0
|
primaryKeys.length > 0
|
||||||
? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
|
?
|
||||||
eb(deferredJoinPrimaryKey, 'in', primaryKeys as any)
|
eb(deferredJoinPrimaryKey, 'in', primaryKeys as any)
|
||||||
: eb(sql`1`, '=', 0),
|
: eb(sql`1`, '=', 0),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -160,4 +160,31 @@ export class PageRepo {
|
|||||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||||
).as('space');
|
).as('space');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPageAndDescendants(parentPageId: string) {
|
||||||
|
return this.db
|
||||||
|
.withRecursive('page_hierarchy', (db) =>
|
||||||
|
db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'slugId', 'title', 'icon', 'content', 'parentPageId', 'spaceId'])
|
||||||
|
.where('id', '=', parentPageId)
|
||||||
|
.unionAll((exp) =>
|
||||||
|
exp
|
||||||
|
.selectFrom('pages as p')
|
||||||
|
.select([
|
||||||
|
'p.id',
|
||||||
|
'p.slugId',
|
||||||
|
'p.title',
|
||||||
|
'p.icon',
|
||||||
|
'p.content',
|
||||||
|
'p.parentPageId',
|
||||||
|
'p.spaceId',
|
||||||
|
])
|
||||||
|
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('page_hierarchy')
|
||||||
|
.selectAll()
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ export class EnvironmentService {
|
|||||||
return this.configService.get<string>('POSTMARK_TOKEN');
|
return this.configService.get<string>('POSTMARK_TOKEN');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDrawioUrl(): string {
|
||||||
|
return this.configService.get<string>('DRAWIO_URL');
|
||||||
|
}
|
||||||
|
|
||||||
isCloud(): boolean {
|
isCloud(): boolean {
|
||||||
const cloudConfig = this.configService
|
const cloudConfig = this.configService
|
||||||
.get<string>('CLOUD', 'false')
|
.get<string>('CLOUD', 'false')
|
||||||
|
|||||||
@@ -11,14 +11,22 @@ import { plainToInstance } from 'class-transformer';
|
|||||||
export class EnvironmentVariables {
|
export class EnvironmentVariables {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsUrl(
|
@IsUrl(
|
||||||
{ protocols: ['postgres', 'postgresql'], require_tld: false },
|
{
|
||||||
|
protocols: ['postgres', 'postgresql'],
|
||||||
|
require_tld: false,
|
||||||
|
allow_underscores: true,
|
||||||
|
},
|
||||||
{ message: 'DATABASE_URL must be a valid postgres connection string' },
|
{ message: 'DATABASE_URL must be a valid postgres connection string' },
|
||||||
)
|
)
|
||||||
DATABASE_URL: string;
|
DATABASE_URL: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsUrl(
|
@IsUrl(
|
||||||
{ protocols: ['redis', 'rediss'], require_tld: false },
|
{
|
||||||
|
protocols: ['redis', 'rediss'],
|
||||||
|
require_tld: false,
|
||||||
|
allow_underscores: true,
|
||||||
|
},
|
||||||
{ message: 'REDIS_URL must be a valid redis connection string' },
|
{ message: 'REDIS_URL must be a valid redis connection string' },
|
||||||
)
|
)
|
||||||
REDIS_URL: string;
|
REDIS_URL: string;
|
||||||
|
|||||||
@@ -22,5 +22,19 @@ export class ExportPageDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
includeFiles?: boolean;
|
includeChildren?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ExportSpaceDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
spaceId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['html', 'markdown'])
|
||||||
|
format: ExportFormat;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
includeAttachments?: boolean;
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
import { ExportPageDto } from './dto/export-dto';
|
import { ExportPageDto, ExportSpaceDto } from './dto/export-dto';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||||
@@ -24,9 +24,10 @@ import { FastifyReply } from 'fastify';
|
|||||||
import { sanitize } from 'sanitize-filename-ts';
|
import { sanitize } from 'sanitize-filename-ts';
|
||||||
import { getExportExtension } from './utils';
|
import { getExportExtension } from './utils';
|
||||||
import { getMimeType } from '../../common/helpers';
|
import { getMimeType } from '../../common/helpers';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class ImportController {
|
export class ExportController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly exportService: ExportService,
|
private readonly exportService: ExportService,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
@@ -54,10 +55,28 @@ export class ImportController {
|
|||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawContent = await this.exportService.exportPage(dto.format, page);
|
|
||||||
|
|
||||||
const fileExt = getExportExtension(dto.format);
|
const fileExt = getExportExtension(dto.format);
|
||||||
const fileName = sanitize(page.title || 'Untitled') + fileExt;
|
const fileName = sanitize(page.title || 'untitled') + fileExt;
|
||||||
|
|
||||||
|
if (dto.includeChildren) {
|
||||||
|
const zipFileBuffer = await this.exportService.exportPageWithChildren(
|
||||||
|
dto.pageId,
|
||||||
|
dto.format,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newName = path.parse(fileName).name + '.zip';
|
||||||
|
|
||||||
|
res.headers({
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Disposition':
|
||||||
|
'attachment; filename="' + encodeURIComponent(newName) + '"',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send(zipFileBuffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawContent = await this.exportService.exportPage(dto.format, page);
|
||||||
|
|
||||||
res.headers({
|
res.headers({
|
||||||
'Content-Type': getMimeType(fileExt),
|
'Content-Type': getMimeType(fileExt),
|
||||||
@@ -67,4 +86,34 @@ export class ImportController {
|
|||||||
|
|
||||||
res.send(rawContent);
|
res.send(rawContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('spaces/export')
|
||||||
|
async exportSpace(
|
||||||
|
@Body() dto: ExportSpaceDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@Res() res: FastifyReply,
|
||||||
|
) {
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportFile = await this.exportService.exportSpace(
|
||||||
|
dto.spaceId,
|
||||||
|
dto.format,
|
||||||
|
dto.includeAttachments,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.headers({
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Disposition':
|
||||||
|
'attachment; filename="' +
|
||||||
|
encodeURIComponent(sanitize(exportFile.fileName)) +
|
||||||
|
'"',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send(exportFile.fileBuffer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
import { ImportController } from './export.controller';
|
import { ExportController } from './export.controller';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [StorageModule],
|
||||||
providers: [ExportService],
|
providers: [ExportService],
|
||||||
controllers: [ImportController],
|
controllers: [ExportController],
|
||||||
})
|
})
|
||||||
export class ExportModule {}
|
export class ExportModule {}
|
||||||
|
|||||||
@@ -1,19 +1,48 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { jsonToHtml } from '../../collaboration/collaboration.util';
|
import { jsonToHtml } from '../../collaboration/collaboration.util';
|
||||||
import { turndown } from './turndown-utils';
|
import { turndown } from './turndown-utils';
|
||||||
import { ExportFormat } from './dto/export-dto';
|
import { ExportFormat } from './dto/export-dto';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import * as JSZip from 'jszip';
|
||||||
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
import {
|
||||||
|
buildTree,
|
||||||
|
computeLocalPath,
|
||||||
|
getAttachmentIds,
|
||||||
|
getExportExtension,
|
||||||
|
getPageTitle,
|
||||||
|
getProsemirrorContent,
|
||||||
|
PageExportTree,
|
||||||
|
replaceInternalLinks,
|
||||||
|
updateAttachmentUrls,
|
||||||
|
} from './utils';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
|
private readonly logger = new Logger(ExportService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly pageRepo: PageRepo,
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
private readonly storageService: StorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async exportPage(format: string, page: Page) {
|
async exportPage(format: string, page: Page) {
|
||||||
const titleNode = {
|
const titleNode = {
|
||||||
type: 'heading',
|
type: 'heading',
|
||||||
attrs: { level: 1 },
|
attrs: { level: 1 },
|
||||||
content: [{ type: 'text', text: page.title }],
|
content: [{ type: 'text', text: getPageTitle(page.title) }],
|
||||||
};
|
};
|
||||||
|
|
||||||
let prosemirrorJson: any = page.content || { type: 'doc', content: [] };
|
const prosemirrorJson: any = getProsemirrorContent(page.content);
|
||||||
|
|
||||||
if (page.title) {
|
if (page.title) {
|
||||||
prosemirrorJson.content.unshift(titleNode);
|
prosemirrorJson.content.unshift(titleNode);
|
||||||
@@ -22,7 +51,13 @@ export class ExportService {
|
|||||||
const pageHtml = jsonToHtml(prosemirrorJson);
|
const pageHtml = jsonToHtml(prosemirrorJson);
|
||||||
|
|
||||||
if (format === ExportFormat.HTML) {
|
if (format === ExportFormat.HTML) {
|
||||||
return `<!DOCTYPE html><html><head><title>${page.title}</title></head><body>${pageHtml}</body></html>`;
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>${getPageTitle(page.title)}</title>
|
||||||
|
</head>
|
||||||
|
<body>${pageHtml}</body>
|
||||||
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === ExportFormat.Markdown) {
|
if (format === ExportFormat.Markdown) {
|
||||||
@@ -31,4 +66,157 @@ export class ExportService {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportPageWithChildren(pageId: string, format: string) {
|
||||||
|
const pages = await this.pageRepo.getPageAndDescendants(pageId);
|
||||||
|
|
||||||
|
if (!pages || pages.length === 0) {
|
||||||
|
throw new BadRequestException('No pages to export');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPageIndex = pages.findIndex((obj) => obj.id === pageId);
|
||||||
|
// set to null to make export of pages with parentId work
|
||||||
|
pages[parentPageIndex].parentPageId = null;
|
||||||
|
|
||||||
|
const tree = buildTree(pages as Page[]);
|
||||||
|
|
||||||
|
const zip = new JSZip();
|
||||||
|
await this.zipPages(tree, format, zip);
|
||||||
|
|
||||||
|
const zipFile = zip.generateNodeStream({
|
||||||
|
type: 'nodebuffer',
|
||||||
|
streamFiles: true,
|
||||||
|
compression: 'DEFLATE',
|
||||||
|
});
|
||||||
|
|
||||||
|
return zipFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportSpace(
|
||||||
|
spaceId: string,
|
||||||
|
format: string,
|
||||||
|
includeAttachments: boolean,
|
||||||
|
) {
|
||||||
|
const space = await this.db
|
||||||
|
.selectFrom('spaces')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', '=', spaceId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!space) {
|
||||||
|
throw new NotFoundException('Space not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = await this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'pages.id',
|
||||||
|
'pages.slugId',
|
||||||
|
'pages.title',
|
||||||
|
'pages.content',
|
||||||
|
'pages.parentPageId',
|
||||||
|
'pages.spaceId'
|
||||||
|
])
|
||||||
|
.where('spaceId', '=', spaceId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const tree = buildTree(pages as Page[]);
|
||||||
|
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
await this.zipPages(tree, format, zip, includeAttachments);
|
||||||
|
|
||||||
|
const zipFile = zip.generateNodeStream({
|
||||||
|
type: 'nodebuffer',
|
||||||
|
streamFiles: true,
|
||||||
|
compression: 'DEFLATE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = `${space.name}-space-export.zip`;
|
||||||
|
return {
|
||||||
|
fileBuffer: zipFile,
|
||||||
|
fileName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async zipPages(
|
||||||
|
tree: PageExportTree,
|
||||||
|
format: string,
|
||||||
|
zip: JSZip,
|
||||||
|
includeAttachments = true,
|
||||||
|
): Promise<void> {
|
||||||
|
const slugIdToPath: Record<string, string> = {};
|
||||||
|
|
||||||
|
computeLocalPath(tree, format, null, '', slugIdToPath);
|
||||||
|
|
||||||
|
const stack: { folder: JSZip; parentPageId: string }[] = [
|
||||||
|
{ folder: zip, parentPageId: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const { folder, parentPageId } = stack.pop();
|
||||||
|
const children = tree[parentPageId] || [];
|
||||||
|
|
||||||
|
for (const page of children) {
|
||||||
|
const childPages = tree[page.id] || [];
|
||||||
|
|
||||||
|
const prosemirrorJson = getProsemirrorContent(page.content);
|
||||||
|
|
||||||
|
const currentPagePath = slugIdToPath[page.slugId];
|
||||||
|
|
||||||
|
let updatedJsonContent = replaceInternalLinks(
|
||||||
|
prosemirrorJson,
|
||||||
|
slugIdToPath,
|
||||||
|
currentPagePath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (includeAttachments) {
|
||||||
|
await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
|
||||||
|
updatedJsonContent = updateAttachmentUrls(updatedJsonContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageTitle = getPageTitle(page.title);
|
||||||
|
const pageExportContent = await this.exportPage(format, {
|
||||||
|
...page,
|
||||||
|
content: updatedJsonContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
folder.file(
|
||||||
|
`${pageTitle}${getExportExtension(format)}`,
|
||||||
|
pageExportContent,
|
||||||
|
);
|
||||||
|
if (childPages.length > 0) {
|
||||||
|
const pageFolder = folder.folder(pageTitle);
|
||||||
|
stack.push({ folder: pageFolder, parentPageId: page.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
|
||||||
|
const attachmentIds = getAttachmentIds(prosemirrorJson);
|
||||||
|
|
||||||
|
if (attachmentIds.length > 0) {
|
||||||
|
const attachments = await this.db
|
||||||
|
.selectFrom('attachments')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', 'in', attachmentIds)
|
||||||
|
.where('spaceId', '=', spaceId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
attachments.map(async (attachment) => {
|
||||||
|
try {
|
||||||
|
const fileBuffer = await this.storageService.read(
|
||||||
|
attachment.filePath,
|
||||||
|
);
|
||||||
|
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
|
||||||
|
zip.file(filePath, fileBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.debug(`Attachment export error ${attachment.id}`, err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function mathBlock(turndownService: TurndownService) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
replacement: function (content: any, node: HTMLInputElement) {
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
return `\n$$${content}$$\n`;
|
return `\n$$\n${content}\n$$\n`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
import { jsonToNode } from 'src/collaboration/collaboration.util';
|
||||||
import { ExportFormat } from './dto/export-dto';
|
import { ExportFormat } from './dto/export-dto';
|
||||||
|
import { Node } from '@tiptap/pm/model';
|
||||||
|
import { validate as isValidUUID } from 'uuid';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
export type PageExportTree = Record<string, Page[]>;
|
||||||
|
|
||||||
export function getExportExtension(format: string) {
|
export function getExportExtension(format: string) {
|
||||||
if (format === ExportFormat.HTML) {
|
if (format === ExportFormat.HTML) {
|
||||||
@@ -10,3 +17,171 @@ export function getExportExtension(format: string) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPageTitle(title: string) {
|
||||||
|
return title ? title : 'untitled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProsemirrorContent(content: any) {
|
||||||
|
return (
|
||||||
|
content ?? {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentIds(prosemirrorJson: any) {
|
||||||
|
const doc = jsonToNode(prosemirrorJson);
|
||||||
|
const attachmentIds = [];
|
||||||
|
|
||||||
|
doc?.descendants((node: Node) => {
|
||||||
|
if (isAttachmentNode(node.type.name)) {
|
||||||
|
if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) {
|
||||||
|
if (!attachmentIds.includes(node.attrs.attachmentId)) {
|
||||||
|
attachmentIds.push(node.attrs.attachmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return attachmentIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAttachmentNode(nodeType: string) {
|
||||||
|
const attachmentNodeTypes = [
|
||||||
|
'attachment',
|
||||||
|
'image',
|
||||||
|
'video',
|
||||||
|
'excalidraw',
|
||||||
|
'drawio',
|
||||||
|
];
|
||||||
|
return attachmentNodeTypes.includes(nodeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAttachmentUrls(prosemirrorJson: any) {
|
||||||
|
const doc = jsonToNode(prosemirrorJson);
|
||||||
|
|
||||||
|
doc?.descendants((node: Node) => {
|
||||||
|
if (isAttachmentNode(node.type.name)) {
|
||||||
|
if (node.attrs.src && node.attrs.src.startsWith('/files')) {
|
||||||
|
//@ts-expect-error
|
||||||
|
node.attrs.src = node.attrs.src.replace('/files', 'files');
|
||||||
|
} else if (node.attrs.url && node.attrs.url.startsWith('/files')) {
|
||||||
|
//@ts-expect-error
|
||||||
|
node.attrs.url = node.attrs.url.replace('/files', 'files');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return doc.toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceInternalLinks(
|
||||||
|
prosemirrorJson: any,
|
||||||
|
slugIdToPath: Record<string, string>,
|
||||||
|
currentPagePath: string,
|
||||||
|
) {
|
||||||
|
const doc = jsonToNode(prosemirrorJson);
|
||||||
|
const internalLinkRegex =
|
||||||
|
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||||
|
|
||||||
|
doc.descendants((node: Node) => {
|
||||||
|
for (const mark of node.marks) {
|
||||||
|
if (mark.type.name === 'link' && mark.attrs.href) {
|
||||||
|
const match = mark.attrs.href.match(internalLinkRegex);
|
||||||
|
if (match) {
|
||||||
|
const markLink = mark.attrs.href;
|
||||||
|
|
||||||
|
const slugId = extractPageSlugId(match[5]);
|
||||||
|
const localPath = slugIdToPath[slugId];
|
||||||
|
|
||||||
|
if (!localPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = computeRelativePath(currentPagePath, localPath);
|
||||||
|
|
||||||
|
//@ts-expect-error
|
||||||
|
mark.attrs.href = relativePath;
|
||||||
|
//@ts-expect-error
|
||||||
|
mark.attrs.target = '_self';
|
||||||
|
if (node.isText) {
|
||||||
|
// if link and text are same, use page title
|
||||||
|
if (markLink === node.text) {
|
||||||
|
//@ts-expect-error
|
||||||
|
node.text = getInternalLinkPageName(relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return doc.toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInternalLinkPageName(path: string): string {
|
||||||
|
return decodeURIComponent(
|
||||||
|
path?.split('/').pop().split('.').slice(0, -1).join('.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPageSlugId(input: string): string {
|
||||||
|
if (!input) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parts = input.split('-');
|
||||||
|
return parts.length > 1 ? parts[parts.length - 1] : input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTree(pages: Page[]): PageExportTree {
|
||||||
|
const tree: PageExportTree = {};
|
||||||
|
const titleCount: Record<string, Record<string, number>> = {};
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const parentPageId = page.parentPageId;
|
||||||
|
|
||||||
|
if (!titleCount[parentPageId]) {
|
||||||
|
titleCount[parentPageId] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = getPageTitle(page.title);
|
||||||
|
|
||||||
|
if (titleCount[parentPageId][title]) {
|
||||||
|
title = `${title} (${titleCount[parentPageId][title]})`;
|
||||||
|
titleCount[parentPageId][getPageTitle(page.title)] += 1;
|
||||||
|
} else {
|
||||||
|
titleCount[parentPageId][title] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.title = title;
|
||||||
|
if (!tree[parentPageId]) {
|
||||||
|
tree[parentPageId] = [];
|
||||||
|
}
|
||||||
|
tree[parentPageId].push(page);
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeLocalPath(
|
||||||
|
tree: PageExportTree,
|
||||||
|
format: string,
|
||||||
|
parentPageId: string | null,
|
||||||
|
currentPath: string,
|
||||||
|
slugIdToPath: Record<string, string>,
|
||||||
|
) {
|
||||||
|
const children = tree[parentPageId] || [];
|
||||||
|
|
||||||
|
for (const page of children) {
|
||||||
|
const title = encodeURIComponent(getPageTitle(page.title));
|
||||||
|
const localPath = `${currentPath}${title}`;
|
||||||
|
slugIdToPath[page.slugId] = `${localPath}${getExportExtension(format)}`;
|
||||||
|
|
||||||
|
computeLocalPath(tree, format, page.id, `${localPath}/`, slugIdToPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRelativePath(from: string, to: string) {
|
||||||
|
return path.relative(path.dirname(from), to);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { MultipartFile } from '@fastify/multipart';
|
|||||||
import { sanitize } from 'sanitize-filename-ts';
|
import { sanitize } from 'sanitize-filename-ts';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
htmlToJson,
|
htmlToJson, jsonToText,
|
||||||
tiptapExtensions,
|
tiptapExtensions,
|
||||||
} from '../../collaboration/collaboration.util';
|
} from '../../collaboration/collaboration.util';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
@@ -72,6 +72,7 @@ export class ImportService {
|
|||||||
slugId: generateSlugId(),
|
slugId: generateSlugId(),
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
content: prosemirrorJson,
|
content: prosemirrorJson,
|
||||||
|
textContent: jsonToText(prosemirrorJson),
|
||||||
ydoc: await this.createYdoc(prosemirrorJson),
|
ydoc: await this.createYdoc(prosemirrorJson),
|
||||||
position: pagePosition,
|
position: pagePosition,
|
||||||
spaceId: spaceId,
|
spaceId: spaceId,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { calloutExtension } from './callout.marked';
|
import { calloutExtension } from './callout.marked';
|
||||||
|
import { mathBlockExtension } from './math-block.marked';
|
||||||
|
import { mathInlineExtension } from "./math-inline.marked";
|
||||||
|
|
||||||
marked.use({
|
marked.use({
|
||||||
renderer: {
|
renderer: {
|
||||||
@@ -26,7 +28,7 @@ marked.use({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
marked.use({ extensions: [calloutExtension] });
|
marked.use({ extensions: [calloutExtension, mathBlockExtension, mathInlineExtension] });
|
||||||
|
|
||||||
export async function markdownToHtml(markdownInput: string): Promise<string> {
|
export async function markdownToHtml(markdownInput: string): Promise<string> {
|
||||||
const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/;
|
const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Token, marked } from 'marked';
|
||||||
|
|
||||||
|
interface MathBlockToken {
|
||||||
|
type: 'mathBlock';
|
||||||
|
text: string;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mathBlockExtension = {
|
||||||
|
name: 'mathBlock',
|
||||||
|
level: 'block',
|
||||||
|
start(src: string) {
|
||||||
|
return src.match(/\$\$/)?.index ?? -1;
|
||||||
|
},
|
||||||
|
tokenizer(src: string): MathBlockToken | undefined {
|
||||||
|
const rule = /^\$\$(?!(\$))([\s\S]+?)\$\$/;
|
||||||
|
const match = rule.exec(src);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: 'mathBlock',
|
||||||
|
raw: match[0],
|
||||||
|
text: match[2]?.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token: Token) {
|
||||||
|
const mathBlockToken = token as MathBlockToken;
|
||||||
|
// parse to prevent escaping slashes
|
||||||
|
const latex = marked
|
||||||
|
.parse(mathBlockToken.text)
|
||||||
|
.toString()
|
||||||
|
.replace(/<(\/)?p>/g, '');
|
||||||
|
|
||||||
|
return `<div data-type="${mathBlockToken.type}" data-katex="true">${latex}</div>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Token, marked } from 'marked';
|
||||||
|
|
||||||
|
interface MathInlineToken {
|
||||||
|
type: 'mathInline';
|
||||||
|
text: string;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineMathRegex = /^\$(?!\s)(.+?)(?<!\s)\$(?!\d)/;
|
||||||
|
|
||||||
|
export const mathInlineExtension = {
|
||||||
|
name: 'mathInline',
|
||||||
|
level: 'inline',
|
||||||
|
start(src: string) {
|
||||||
|
let index: number;
|
||||||
|
let indexSrc = src;
|
||||||
|
|
||||||
|
while (indexSrc) {
|
||||||
|
index = indexSrc.indexOf('$');
|
||||||
|
if (index === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const f = index === 0 || indexSrc.charAt(index - 1) === ' ';
|
||||||
|
if (f) {
|
||||||
|
const possibleKatex = indexSrc.substring(index);
|
||||||
|
if (possibleKatex.match(inlineMathRegex)) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, '');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tokenizer(src: string): MathInlineToken | undefined {
|
||||||
|
const match = inlineMathRegex.exec(src);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type: 'mathInline',
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1]?.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token: Token) {
|
||||||
|
const mathInlineToken = token as MathInlineToken;
|
||||||
|
// parse to prevent escaping slashes
|
||||||
|
const latex = marked
|
||||||
|
.parse(mathInlineToken.text)
|
||||||
|
.toString()
|
||||||
|
.replace(/<(\/)?p>/g, '');
|
||||||
|
|
||||||
|
return `<span data-type="${mathInlineToken.type}" data-katex="true">${latex}</span>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -25,7 +25,7 @@ export const mailDriverConfigProvider = {
|
|||||||
const driver = environmentService.getMailDriver().toLocaleLowerCase();
|
const driver = environmentService.getMailDriver().toLocaleLowerCase();
|
||||||
|
|
||||||
switch (driver) {
|
switch (driver) {
|
||||||
case MailOption.SMTP:
|
case MailOption.SMTP: {
|
||||||
let auth = undefined;
|
let auth = undefined;
|
||||||
if (
|
if (
|
||||||
environmentService.getSmtpUsername() &&
|
environmentService.getSmtpUsername() &&
|
||||||
@@ -44,9 +44,10 @@ export const mailDriverConfigProvider = {
|
|||||||
connectionTimeout: 30 * 1000, // 30 seconds
|
connectionTimeout: 30 * 1000, // 30 seconds
|
||||||
auth,
|
auth,
|
||||||
secure: environmentService.getSmtpSecure(),
|
secure: environmentService.getSmtpSecure(),
|
||||||
ignoreTLS: environmentService.getSmtpIgnoreTLS()
|
ignoreTLS: environmentService.getSmtpIgnoreTLS(),
|
||||||
} as SMTPTransport.Options,
|
} as SMTPTransport.Options,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case MailOption.Postmark:
|
case MailOption.Postmark:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { QueueName } from './constants';
|
|||||||
host: redisConfig.host,
|
host: redisConfig.host,
|
||||||
port: redisConfig.port,
|
port: redisConfig.port,
|
||||||
password: redisConfig.password,
|
password: redisConfig.password,
|
||||||
|
db: redisConfig.db,
|
||||||
retryStrategy: createRetryStrategy(),
|
retryStrategy: createRetryStrategy(),
|
||||||
},
|
},
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export class StaticModule implements OnModuleInit {
|
|||||||
ENV: this.environmentService.getNodeEnv(),
|
ENV: this.environmentService.getNodeEnv(),
|
||||||
APP_URL: this.environmentService.getAppUrl(),
|
APP_URL: this.environmentService.getAppUrl(),
|
||||||
IS_CLOUD: this.environmentService.isCloud(),
|
IS_CLOUD: this.environmentService.isCloud(),
|
||||||
FILE_UPLOAD_SIZE_LIMIT: this.environmentService.getFileUploadSizeLimit()
|
FILE_UPLOAD_SIZE_LIMIT: this.environmentService.getFileUploadSizeLimit(),
|
||||||
|
DRAWIO_URL: this.environmentService.getDrawioUrl()
|
||||||
};
|
};
|
||||||
|
|
||||||
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const storageDriverConfigProvider = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
case StorageOption.S3:
|
case StorageOption.S3:
|
||||||
const s3Config = {
|
{ const s3Config = {
|
||||||
driver,
|
driver,
|
||||||
config: {
|
config: {
|
||||||
region: environmentService.getAwsS3Region(),
|
region: environmentService.getAwsS3Region(),
|
||||||
@@ -68,7 +68,7 @@ export const storageDriverConfigProvider = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return s3Config;
|
return s3Config; }
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown storage driver: ${driver}`);
|
throw new Error(`Unknown storage driver: ${driver}`);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Server, Socket } from 'socket.io';
|
|||||||
import { TokenService } from '../core/auth/services/token.service';
|
import { TokenService } from '../core/auth/services/token.service';
|
||||||
import { JwtType } from '../core/auth/dto/jwt-payload';
|
import { JwtType } from '../core/auth/dto/jwt-payload';
|
||||||
import { OnModuleDestroy } from '@nestjs/common';
|
import { OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: { origin: '*' },
|
cors: { origin: '*' },
|
||||||
@@ -17,7 +18,10 @@ import { OnModuleDestroy } from '@nestjs/common';
|
|||||||
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
server: Server;
|
||||||
constructor(private tokenService: TokenService) {}
|
constructor(
|
||||||
|
private tokenService: TokenService,
|
||||||
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -27,24 +31,43 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
|||||||
if (token.type !== JwtType.ACCESS) {
|
if (token.type !== JwtType.ACCESS) {
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = token.sub;
|
||||||
|
const workspaceId = token.workspaceId;
|
||||||
|
|
||||||
|
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||||
|
|
||||||
|
const workspaceRoom = `workspace-${workspaceId}`;
|
||||||
|
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
|
||||||
|
|
||||||
|
client.join([workspaceRoom, ...spaceRooms]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('message')
|
@SubscribeMessage('message')
|
||||||
handleMessage(client: Socket, data: string): void {
|
handleMessage(client: Socket, data: any): void {
|
||||||
client.broadcast.emit('message', data);
|
const spaceEvents = [
|
||||||
}
|
'updateOne',
|
||||||
|
'addTreeNode',
|
||||||
|
'moveTreeNode',
|
||||||
|
'deleteTreeNode',
|
||||||
|
];
|
||||||
|
|
||||||
@SubscribeMessage('messageToRoom')
|
if (spaceEvents.includes(data?.operation) && data?.spaceId) {
|
||||||
handleSendMessageToRoom(@MessageBody() message: any) {
|
const room = this.getSpaceRoomName(data.spaceId);
|
||||||
this.server.to(message?.roomId).emit('messageToRoom', message);
|
client.broadcast.to(room).emit('message', data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.broadcast.emit('message', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('join-room')
|
@SubscribeMessage('join-room')
|
||||||
handleJoinRoom(client: Socket, @MessageBody() roomName: string): void {
|
handleJoinRoom(client: Socket, @MessageBody() roomName: string): void {
|
||||||
client.join(roomName);
|
// if room is a space, check if user has permissions
|
||||||
|
//client.join(roomName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('leave-room')
|
@SubscribeMessage('leave-room')
|
||||||
@@ -57,4 +80,8 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
|||||||
this.server.close();
|
this.server.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSpaceRoomName(spaceId: string): string {
|
||||||
|
return `space-${spaceId}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-46
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "docmost",
|
"name": "docmost",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"version": "0.5.0",
|
"version": "0.6.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nx run-many -t build",
|
"build": "nx run-many -t build",
|
||||||
@@ -17,63 +17,65 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@hocuspocus/extension-redis": "^2.13.5",
|
"@hocuspocus/extension-redis": "^2.14.0",
|
||||||
"@hocuspocus/provider": "^2.13.5",
|
"@hocuspocus/provider": "^2.14.0",
|
||||||
"@hocuspocus/server": "^2.13.5",
|
"@hocuspocus/server": "^2.14.0",
|
||||||
"@hocuspocus/transformer": "^2.13.5",
|
"@hocuspocus/transformer": "^2.14.0",
|
||||||
"@joplin/turndown": "^4.0.74",
|
"@joplin/turndown": "^4.0.74",
|
||||||
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
||||||
"@sindresorhus/slugify": "^2.2.1",
|
"@sindresorhus/slugify": "^2.2.1",
|
||||||
"@tiptap/core": "^2.6.6",
|
"@tiptap/core": "^2.10.3",
|
||||||
"@tiptap/extension-code-block": "^2.6.6",
|
"@tiptap/extension-code-block": "^2.10.3",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.6.6",
|
"@tiptap/extension-code-block-lowlight": "^2.10.3",
|
||||||
"@tiptap/extension-collaboration": "^2.6.6",
|
"@tiptap/extension-collaboration": "^2.10.3",
|
||||||
"@tiptap/extension-collaboration-cursor": "^2.6.6",
|
"@tiptap/extension-collaboration-cursor": "^2.10.3",
|
||||||
"@tiptap/extension-color": "^2.6.6",
|
"@tiptap/extension-color": "^2.10.3",
|
||||||
"@tiptap/extension-document": "^2.6.6",
|
"@tiptap/extension-document": "^2.10.3",
|
||||||
"@tiptap/extension-heading": "^2.6.6",
|
"@tiptap/extension-heading": "^2.10.3",
|
||||||
"@tiptap/extension-highlight": "^2.6.6",
|
"@tiptap/extension-highlight": "^2.10.3",
|
||||||
"@tiptap/extension-history": "^2.6.6",
|
"@tiptap/extension-history": "^2.10.3",
|
||||||
"@tiptap/extension-image": "^2.6.6",
|
"@tiptap/extension-image": "^2.10.3",
|
||||||
"@tiptap/extension-link": "^2.6.6",
|
"@tiptap/extension-link": "^2.10.3",
|
||||||
"@tiptap/extension-list-item": "^2.6.6",
|
"@tiptap/extension-list-item": "^2.10.3",
|
||||||
"@tiptap/extension-list-keymap": "^2.6.6",
|
"@tiptap/extension-list-keymap": "^2.10.3",
|
||||||
"@tiptap/extension-mention": "^2.6.6",
|
"@tiptap/extension-mention": "^2.10.3",
|
||||||
"@tiptap/extension-placeholder": "^2.6.6",
|
"@tiptap/extension-placeholder": "^2.10.3",
|
||||||
"@tiptap/extension-subscript": "^2.6.6",
|
"@tiptap/extension-subscript": "^2.10.3",
|
||||||
"@tiptap/extension-superscript": "^2.6.6",
|
"@tiptap/extension-superscript": "^2.10.3",
|
||||||
"@tiptap/extension-table": "^2.6.6",
|
"@tiptap/extension-table": "^2.10.3",
|
||||||
"@tiptap/extension-table-cell": "^2.6.6",
|
"@tiptap/extension-table-cell": "^2.10.3",
|
||||||
"@tiptap/extension-table-header": "^2.6.6",
|
"@tiptap/extension-table-header": "^2.10.3",
|
||||||
"@tiptap/extension-table-row": "^2.6.6",
|
"@tiptap/extension-table-row": "^2.10.3",
|
||||||
"@tiptap/extension-task-item": "^2.6.6",
|
"@tiptap/extension-task-item": "^2.10.3",
|
||||||
"@tiptap/extension-task-list": "^2.6.6",
|
"@tiptap/extension-task-list": "^2.10.3",
|
||||||
"@tiptap/extension-text": "^2.6.6",
|
"@tiptap/extension-text": "^2.10.3",
|
||||||
"@tiptap/extension-text-align": "^2.6.6",
|
"@tiptap/extension-text-align": "^2.10.3",
|
||||||
"@tiptap/extension-text-style": "^2.6.6",
|
"@tiptap/extension-text-style": "^2.10.3",
|
||||||
"@tiptap/extension-typography": "^2.6.6",
|
"@tiptap/extension-typography": "^2.10.3",
|
||||||
"@tiptap/extension-underline": "^2.6.6",
|
"@tiptap/extension-underline": "^2.10.3",
|
||||||
"@tiptap/extension-youtube": "^2.6.6",
|
"@tiptap/extension-youtube": "^2.10.3",
|
||||||
"@tiptap/html": "^2.6.6",
|
"@tiptap/html": "^2.10.3",
|
||||||
"@tiptap/pm": "^2.6.6",
|
"@tiptap/pm": "^2.10.3",
|
||||||
"@tiptap/react": "^2.6.6",
|
"@tiptap/react": "^2.10.3",
|
||||||
"@tiptap/starter-kit": "^2.6.6",
|
"@tiptap/starter-kit": "^2.10.3",
|
||||||
"@tiptap/suggestion": "^2.6.6",
|
"@tiptap/suggestion": "^2.10.3",
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
"dompurify": "^3.2.1",
|
||||||
"fractional-indexing-jittered": "^0.9.1",
|
"fractional-indexing-jittered": "^0.9.1",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"uuid": "^10.0.0",
|
"jszip": "^3.10.1",
|
||||||
|
"uuid": "^11.0.3",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"yjs": "^13.6.18"
|
"yjs": "^13.6.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nx/js": "19.6.3",
|
"@nx/js": "20.1.3",
|
||||||
"@types/bytes": "^3.1.4",
|
"@types/bytes": "^3.1.4",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^9.1.0",
|
||||||
"nx": "19.6.3",
|
"nx": "20.1.3",
|
||||||
"tsx": "^4.19.0"
|
"tsx": "^4.19.2"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface CustomCodeBlockOptions extends CodeBlockLowlightOptions {
|
|||||||
view: any;
|
view: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TAB_CHAR = "\u00A0\u00A0";
|
||||||
|
|
||||||
export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
|
export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
|
||||||
{
|
{
|
||||||
selectable: true,
|
selectable: true,
|
||||||
@@ -18,8 +20,26 @@ export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
Tab: () => {
|
||||||
|
if (this.editor.isActive("codeBlock")) {
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.command(({ tr }) => {
|
||||||
|
tr.insertText(TAB_CHAR);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { mergeAttributes } from "@tiptap/core";
|
||||||
import TiptapLink from "@tiptap/extension-link";
|
import TiptapLink from "@tiptap/extension-link";
|
||||||
import { Plugin } from "@tiptap/pm/state";
|
import { Plugin } from "@tiptap/pm/state";
|
||||||
import { EditorView } from "@tiptap/pm/view";
|
import { EditorView } from "@tiptap/pm/view";
|
||||||
@@ -5,6 +6,24 @@ import { EditorView } from "@tiptap/pm/view";
|
|||||||
export const LinkExtension = TiptapLink.extend({
|
export const LinkExtension = TiptapLink.extend({
|
||||||
inclusive: false,
|
inclusive: false,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"a",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
|
class: "link",
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
const { editor } = this;
|
const { editor } = this;
|
||||||
|
|
||||||
|
|||||||
Generated
+4405
-2827
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user