mirror of
https://github.com/docmost/docmost.git
synced 2025-11-11 07:42:05 +10:00
Compare commits
35 Commits
v0.5.0
...
poc/server
| Author | SHA1 | Date | |
|---|---|---|---|
| 5506fe5e73 | |||
| 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_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',
|
||||
},
|
||||
}
|
||||
36
apps/client/eslint.config.mjs
Normal file
36
apps/client/eslint.config.mjs
Normal file
@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -6,6 +6,7 @@
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Docmost</title>
|
||||
<!--meta-tags-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -1,73 +1,76 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.1",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.1",
|
||||
"@casl/ability": "^6.7.2",
|
||||
"@casl/react": "^4.0.0",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@mantine/core": "^7.12.2",
|
||||
"@mantine/form": "^7.12.2",
|
||||
"@mantine/hooks": "^7.12.2",
|
||||
"@mantine/modals": "^7.12.2",
|
||||
"@mantine/notifications": "^7.12.2",
|
||||
"@mantine/spotlight": "^7.12.2",
|
||||
"@tabler/icons-react": "^3.14.0",
|
||||
"@tanstack/react-query": "^5.53.2",
|
||||
"axios": "^1.7.7",
|
||||
"@mantine/core": "^7.14.2",
|
||||
"@mantine/form": "^7.14.2",
|
||||
"@mantine/hooks": "^7.14.2",
|
||||
"@mantine/modals": "^7.14.2",
|
||||
"@mantine/notifications": "^7.14.2",
|
||||
"@mantine/spotlight": "^7.14.2",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"@tanstack/react-query": "^5.61.4",
|
||||
"axios": "^1.7.8",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"jotai": "^2.9.3",
|
||||
"jotai": "^2.10.3",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.11",
|
||||
"lowlight": "^3.1.0",
|
||||
"mermaid": "^11.0.2",
|
||||
"lowlight": "^3.2.0",
|
||||
"mermaid": "^11.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "^3.4.0",
|
||||
"react-clear-modal": "^2.0.9",
|
||||
"react-clear-modal": "^2.0.11",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^0.2.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-drawio": "^1.0.1",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"react-router-dom": "^7.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"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"
|
||||
},
|
||||
"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/js-cookie": "^3.0.6",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "22.5.2",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"@types/node": "22.10.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.13.0",
|
||||
"optics-ts": "^2.4.1",
|
||||
"postcss": "^8.4.43",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.8"
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +62,13 @@ export default function App() {
|
||||
<>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="/home" />} />
|
||||
<Route path={"/share/:id"} element={
|
||||
<ErrorBoundary
|
||||
fallback={<>Failed to load home. An error occurred.</>}
|
||||
>
|
||||
<Home />
|
||||
</ErrorBoundary>
|
||||
}/>
|
||||
<Route path={"/login"} element={<LoginPage />} />
|
||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||
|
||||
149
apps/client/src/components/common/export-modal.tsx
Normal file
149
apps/client/src/components/common/export-modal.tsx
Normal file
@ -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 React from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
asideStateAtom,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
mobileSidebarAtom, sidebarWidthAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||
@ -21,6 +21,46 @@ export default function GlobalAppShell({
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
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 isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
@ -33,7 +73,7 @@ export default function GlobalAppShell({
|
||||
header={{ height: 45 }}
|
||||
navbar={
|
||||
!isHomeRoute && {
|
||||
width: 300,
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
@ -54,7 +94,8 @@ export default function GlobalAppShell({
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
{!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 />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
</AppShell.Navbar>
|
||||
|
||||
@ -19,3 +19,5 @@ export const asideStateAtom = atom<AsideStateType>({
|
||||
tab: "",
|
||||
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 { jwtDecode } from "jwt-decode";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export default function useAuth() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -30,6 +31,7 @@ export default function useAuth() {
|
||||
|
||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||
const [authToken, setAuthToken] = useAtom(authTokensAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleSignIn = async (data: ILogin) => {
|
||||
setIsLoading(true);
|
||||
@ -136,7 +138,8 @@ export default function useAuth() {
|
||||
setAuthToken(null);
|
||||
setCurrentUser(null);
|
||||
Cookies.remove("authTokens");
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
queryClient.clear();
|
||||
window.location.replace(APP_ROUTE.AUTH.LOGIN);;
|
||||
};
|
||||
|
||||
const handleForgotPassword = async (data: IForgotPassword) => {
|
||||
|
||||
@ -25,7 +25,6 @@ export function useCommentsQuery(
|
||||
params: ICommentParams,
|
||||
): UseQueryResult<IPagination<IComment>, Error> {
|
||||
return useQuery({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
queryKey: RQ_KEY(params.pageId),
|
||||
queryFn: () => getPageComments(params),
|
||||
enabled: !!params.pageId,
|
||||
|
||||
@ -23,7 +23,7 @@ import {
|
||||
showCommentPopupAtom,
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import { useAtom } from "jotai";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||
|
||||
@ -84,7 +84,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
name: "comment",
|
||||
isActive: () => props.editor.isActive("comment"),
|
||||
command: () => {
|
||||
const commentId = uuidv4();
|
||||
const commentId = uuid7();
|
||||
|
||||
props.editor.chain().focus().setCommentDecoration().run();
|
||||
setDraftCommentId(commentId);
|
||||
|
||||
@ -3,7 +3,7 @@ import { ActionIcon, Card, Image, Modal, Text, useComputedColorScheme } from '@m
|
||||
import { useRef, useState } from 'react';
|
||||
import { uploadFile } from '@/features/page/services/page-service.ts';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { getFileUrl } from '@/lib/config.ts';
|
||||
import { getDrawioUrl, getFileUrl } from '@/lib/config.ts';
|
||||
import {
|
||||
DrawIoEmbed,
|
||||
DrawIoEmbedRef,
|
||||
@ -40,7 +40,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
reader.onloadend = () => {
|
||||
let base64data = (reader.result || '') as string;
|
||||
const base64data = (reader.result || '') as string;
|
||||
setInitialXML(base64data);
|
||||
};
|
||||
}
|
||||
@ -87,6 +87,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
<DrawIoEmbed
|
||||
ref={drawioRef}
|
||||
xml={initialXML}
|
||||
baseUrl={getDrawioUrl()}
|
||||
urlParameters={{
|
||||
ui: computedColorScheme === 'light' ? 'kennedy' : 'dark',
|
||||
spin: true,
|
||||
|
||||
@ -10,7 +10,10 @@ export const embedProviders: IEmbedProvider[] = [
|
||||
id: 'loom',
|
||||
name: 'Loom',
|
||||
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]}`;
|
||||
}
|
||||
},
|
||||
@ -20,6 +23,9 @@ export const embedProviders: IEmbedProvider[] = [
|
||||
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
const path = url.split('airtable.com/');
|
||||
if(url.includes("/embed/")){
|
||||
return url;
|
||||
}
|
||||
return `https://airtable.com/embed/${path[1]}`;
|
||||
}
|
||||
},
|
||||
@ -43,7 +49,10 @@ export const embedProviders: IEmbedProvider[] = [
|
||||
id: 'miro',
|
||||
name: 'Miro',
|
||||
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`;
|
||||
}
|
||||
},
|
||||
@ -51,7 +60,10 @@ export const embedProviders: IEmbedProvider[] = [
|
||||
id: 'youtube',
|
||||
name: 'YouTube',
|
||||
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]}`;
|
||||
}
|
||||
},
|
||||
|
||||
@ -38,7 +38,7 @@ export default function MathInlineView(props: NodeViewProps) {
|
||||
renderMath(preview || "", mathPreviewContainer.current);
|
||||
} else if (preview !== null) {
|
||||
queueMicrotask(() => {
|
||||
updateAttributes({ text: preview });
|
||||
updateAttributes({ text: preview.trim() });
|
||||
});
|
||||
}
|
||||
}, [preview, isEditing]);
|
||||
@ -97,7 +97,7 @@ export default function MathInlineView(props: NodeViewProps) {
|
||||
ref={textAreaRef}
|
||||
draggable={false}
|
||||
classNames={{ input: classes.textInput }}
|
||||
value={preview?.trim() ?? ""}
|
||||
value={preview ?? ""}
|
||||
placeholder={"E = mc^2"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
transition: background-color 0.2s;
|
||||
padding: 0 0.25rem;
|
||||
margin: 0 0.1rem;
|
||||
user-select: none;
|
||||
|
||||
&.empty {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
|
||||
@ -30,6 +31,7 @@
|
||||
transition: background-color 0.2s;
|
||||
margin: 0 0.1rem;
|
||||
overflow-x: auto;
|
||||
user-select: none;
|
||||
|
||||
.textInput {
|
||||
width: 400px;
|
||||
|
||||
@ -456,7 +456,7 @@ export const getSuggestionItems = ({
|
||||
const fuzzyMatch = (query: string, target: string) => {
|
||||
let queryIndex = 0;
|
||||
target = target.toLowerCase();
|
||||
for (let char of target) {
|
||||
for (const char of target) {
|
||||
if (query[queryIndex] === char) queryIndex++;
|
||||
if (queryIndex === query.length) return true;
|
||||
}
|
||||
|
||||
@ -30,8 +30,7 @@ export function FullEditor({
|
||||
return (
|
||||
<Container
|
||||
fluid={fullPageWidth}
|
||||
{...(fullPageWidth && { mx: 80 })}
|
||||
size={850}
|
||||
size={!fullPageWidth && 850}
|
||||
className={classes.editor}
|
||||
>
|
||||
<MemoizedTitleEditor
|
||||
|
||||
@ -97,8 +97,8 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
||||
}, [remoteProvider, localProvider]);
|
||||
|
||||
const extensions = [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser.user),
|
||||
... mainExtensions,
|
||||
... collabExtensions(remoteProvider, currentUser.user),
|
||||
];
|
||||
|
||||
const editor = useEditor(
|
||||
@ -184,6 +184,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div onClick={() => editor.commands.focus('end')} style={{ paddingBottom: '20vh' }}></div>
|
||||
</div>
|
||||
) : (
|
||||
<EditorSkeleton />
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: inherit;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
/* Code styling */
|
||||
@ -103,12 +103,12 @@
|
||||
|
||||
@mixin where-light {
|
||||
background-color: var(--code-bg, var(--mantine-color-gray-1));
|
||||
color: var(--mantine-color-black);
|
||||
color: var(--mantine-color-pink-7);
|
||||
}
|
||||
|
||||
@mixin where-dark {
|
||||
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,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import {
|
||||
useUpdatePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
@ -39,7 +37,11 @@ export function TitleEditor({
|
||||
}: TitleEditorProps) {
|
||||
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
|
||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const {
|
||||
data: updatedPageData,
|
||||
mutate: updatePageMutation,
|
||||
status,
|
||||
} = useUpdatePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
@ -47,7 +49,6 @@ export function TitleEditor({
|
||||
const navigate = useNavigate();
|
||||
const [activePageId, setActivePageId] = useState(pageId);
|
||||
|
||||
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
Document.extend({
|
||||
@ -87,24 +88,29 @@ export function TitleEditor({
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTitle !== null && activePageId === pageId) {
|
||||
updatePageMutation.mutate({
|
||||
updatePageMutation({
|
||||
pageId: pageId,
|
||||
title: debouncedTitle,
|
||||
});
|
||||
}
|
||||
}, [debouncedTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "success" && updatedPageData) {
|
||||
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
|
||||
setTreeData(newTreeData);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "updateOne",
|
||||
spaceId: updatedPageData.spaceId,
|
||||
entity: ["pages"],
|
||||
id: pageId,
|
||||
payload: { title: debouncedTitle, slugId: slugId },
|
||||
});
|
||||
}, 50);
|
||||
|
||||
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
|
||||
setTreeData(newTreeData);
|
||||
}
|
||||
}, [debouncedTitle]);
|
||||
}, [updatedPageData, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (titleEditor && title !== titleEditor.getText()) {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
.breadcrumbs {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
a {
|
||||
color: var(--mantine-color-default-color);
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.mantine-Breadcrumbs-breadcrumb {
|
||||
|
||||
@ -2,7 +2,7 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsHorizontal,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconFileExport,
|
||||
IconHistory,
|
||||
IconLink,
|
||||
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 { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
|
||||
interface PageHeaderMenuProps {
|
||||
readOnly?: boolean;
|
||||
@ -126,7 +127,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconDownload size={16} />}
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
onClick={openExportModal}
|
||||
>
|
||||
Export
|
||||
@ -154,8 +155,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<PageExportModal
|
||||
pageId={page.id}
|
||||
<ExportModal
|
||||
type="page"
|
||||
id={page.id}
|
||||
open={exportOpened}
|
||||
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 { useState } from "react";
|
||||
import * as React from "react";
|
||||
@ -57,8 +57,18 @@ export default function PageExportModal({
|
||||
<Text size="md">Format</Text>
|
||||
</div>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
|
||||
</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">
|
||||
<Button onClick={onClose} variant="default">
|
||||
Cancel
|
||||
|
||||
@ -119,7 +119,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={2}>
|
||||
<FileButton onChange={handleFileUpload} accept="text/markdown" multiple>
|
||||
<FileButton onChange={handleFileUpload} accept=".md" multiple>
|
||||
{(props) => (
|
||||
<Button
|
||||
justify="start"
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconDotsVertical,
|
||||
IconFileDescription,
|
||||
IconFileDescription, IconFileExport,
|
||||
IconLink,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
@ -39,7 +39,12 @@ import {
|
||||
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
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 { useQueryEmit } from "@/features/websocket/use-query-emit.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 { extractPageSlugId } from "@/lib";
|
||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
@ -133,13 +139,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
flatTreeItems = [
|
||||
...flatTreeItems,
|
||||
...children.filter(
|
||||
(child) => !flatTreeItems.some((item) => item.id === child.id),
|
||||
(child) => !flatTreeItems.some((item) => item.id === child.id)
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const fetchPromises = ancestors.map((ancestor) =>
|
||||
fetchAndUpdateChildren(ancestor),
|
||||
fetchAndUpdateChildren(ancestor)
|
||||
);
|
||||
|
||||
// Wait for all fetch operations to complete
|
||||
@ -153,7 +159,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
const updatedTree = appendNodeChildren(
|
||||
data,
|
||||
rootChild.id,
|
||||
rootChild.children,
|
||||
rootChild.children
|
||||
);
|
||||
setData(updatedTree);
|
||||
|
||||
@ -191,13 +197,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
<div ref={mergedRef} className={classes.treeContainer}>
|
||||
{rootElement.current && (
|
||||
<Tree
|
||||
data={data}
|
||||
data={data.filter((node) => node?.spaceId === spaceId)}
|
||||
disableDrag={readOnly}
|
||||
disableDrop={readOnly}
|
||||
disableEdit={readOnly}
|
||||
{...controllers}
|
||||
width={width}
|
||||
height={height}
|
||||
height={rootElement.current.clientHeight}
|
||||
ref={treeApiRef}
|
||||
openByDefault={false}
|
||||
disableMultiSelection={true}
|
||||
@ -248,7 +254,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
const updatedTreeData = appendNodeChildren(
|
||||
treeData,
|
||||
node.data.id,
|
||||
childrenTree,
|
||||
childrenTree
|
||||
);
|
||||
|
||||
setTreeData(updatedTreeData);
|
||||
@ -279,6 +285,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "updateOne",
|
||||
spaceId: node.data.spaceId,
|
||||
entity: ["pages"],
|
||||
id: node.id,
|
||||
payload: { icon: emoji.native },
|
||||
@ -293,6 +300,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "updateOne",
|
||||
spaceId: node.data.spaceId,
|
||||
entity: ["pages"],
|
||||
id: node.id,
|
||||
payload: { icon: null },
|
||||
@ -400,6 +408,8 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { spaceSlug } = useParams();
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@ -409,56 +419,76 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<IconDotsVertical
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={2}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<>
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<IconDotsVertical
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={2}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyLink();
|
||||
}}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyLink();
|
||||
}}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
|
||||
{!(treeApi.props.disableEdit as boolean) && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openExportModal();
|
||||
}}
|
||||
>
|
||||
Export page
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
c="red"
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{!(treeApi.props.disableEdit as boolean) && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
c="red"
|
||||
leftSection={
|
||||
<IconTrash size={16} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</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(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: spaceId,
|
||||
payload: {
|
||||
parentId,
|
||||
index,
|
||||
data
|
||||
}
|
||||
data,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
|
||||
const pageUrl = buildPageUrl(
|
||||
spaceSlug,
|
||||
createdPage.slugId,
|
||||
createdPage.title,
|
||||
createdPage.title
|
||||
);
|
||||
navigate(pageUrl);
|
||||
return data;
|
||||
@ -156,18 +157,16 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
// check if the previous still has children
|
||||
// if no children left, change 'hasChildren' to false, to make the page toggle arrows work properly
|
||||
const childrenCount = previousParent.children.filter(
|
||||
(child) => child.id !== draggedNodeId,
|
||||
(child) => child.id !== draggedNodeId
|
||||
).length;
|
||||
if (childrenCount === 0) {
|
||||
tree.update({
|
||||
id: previousParent.id,
|
||||
changes: { ... previousParent.data, hasChildren: false } as any,
|
||||
changes: { ...previousParent.data, hasChildren: false } as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//console.log()
|
||||
|
||||
setData(tree.data);
|
||||
|
||||
const payload: IMovePage = {
|
||||
@ -182,7 +181,13 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
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);
|
||||
} catch (error) {
|
||||
@ -214,17 +219,17 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
setData(tree.data);
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "deleteTreeNode",
|
||||
payload: { node: node.data }
|
||||
spaceId: spaceId,
|
||||
payload: { node: node.data },
|
||||
});
|
||||
}, 50);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to delete page:", error);
|
||||
}
|
||||
|
||||
@ -3,10 +3,12 @@
|
||||
}
|
||||
|
||||
.treeContainer {
|
||||
display: flex;
|
||||
height: 68vh;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
|
||||
> div, > div > .tree {
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.node {
|
||||
|
||||
@ -48,6 +48,7 @@ export interface IPageInput {
|
||||
export interface IExportPageParams {
|
||||
pageId: string;
|
||||
format: ExportFormat;
|
||||
includeChildren?: boolean;
|
||||
}
|
||||
|
||||
export enum ExportFormat {
|
||||
|
||||
@ -24,7 +24,7 @@ export default function SpaceSettingsModal({
|
||||
const {data: space, isLoading} = useSpaceQuery(spaceId);
|
||||
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.section {
|
||||
@ -18,6 +19,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sectionPages {
|
||||
margin-bottom: 0;
|
||||
overflow-y: hidden;
|
||||
|
||||
.pages {
|
||||
height: 100%;
|
||||
padding-bottom: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.menuItems {
|
||||
padding-left: 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,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { spotlight } from '@mantine/spotlight';
|
||||
} from "@mantine/core";
|
||||
import { spotlight } from "@mantine/spotlight";
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconDots,
|
||||
IconFileExport,
|
||||
IconHome,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
} from '@tabler/icons-react';
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import classes from './space-sidebar.module.css';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { SearchSpotlight } from '@/features/search/search-spotlight.tsx';
|
||||
import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom.ts';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx';
|
||||
import { useGetSpaceBySlugQuery } from '@/features/space/queries/space-query.ts';
|
||||
import { getSpaceUrl } from '@/lib/config.ts';
|
||||
import SpaceTree from '@/features/page/tree/components/space-tree.tsx';
|
||||
import { useSpaceAbility } from '@/features/space/permissions/use-space-ability.ts';
|
||||
import classes from "./space-sidebar.module.css";
|
||||
import React, { useMemo } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '@/features/space/permissions/permissions.type.ts';
|
||||
import PageImportModal from '@/features/page/components/page-import-modal.tsx';
|
||||
import { SwitchSpace } from './switch-space';
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
|
||||
import { SwitchSpace } from "./switch-space";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
|
||||
export function SpaceSidebar() {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
@ -45,14 +47,14 @@ export function SpaceSidebar() {
|
||||
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
|
||||
if (!space) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
function handleCreatePage() {
|
||||
tree?.create({ parentId: null, type: 'internal', index: 0 });
|
||||
tree?.create({ parentId: null, type: "internal", index: 0 });
|
||||
}
|
||||
|
||||
return (
|
||||
@ -61,7 +63,7 @@ export function SpaceSidebar() {
|
||||
<div
|
||||
className={classes.section}
|
||||
style={{
|
||||
border: 'none',
|
||||
border: "none",
|
||||
marginTop: 2,
|
||||
marginBottom: 3,
|
||||
}}
|
||||
@ -78,7 +80,7 @@ export function SpaceSidebar() {
|
||||
classes.menu,
|
||||
location.pathname.toLowerCase() === getSpaceUrl(spaceSlug)
|
||||
? classes.activeButton
|
||||
: ''
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div className={classes.menuItemInner}>
|
||||
@ -134,7 +136,7 @@ export function SpaceSidebar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.section}>
|
||||
<div className={clsx(classes.section, classes.sectionPages)}>
|
||||
<Group className={classes.pagesHeader} justify="space-between">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
Pages
|
||||
@ -191,6 +193,8 @@ interface SpaceMenuProps {
|
||||
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||
useDisclosure(false);
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -215,6 +219,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
Import pages
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
onClick={openExportModal}
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
>
|
||||
Export space
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
@ -231,6 +242,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
open={importOpened}
|
||||
onClose={closeImportModal}
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
type="space"
|
||||
id={spaceId}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ interface SwitchSpaceProps {
|
||||
}
|
||||
|
||||
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
||||
const [opened, { close, open, toggle }] = useDisclosure(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
@ -28,7 +27,6 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
opened={opened}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
@ -37,7 +35,6 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronDown size={18} />}
|
||||
color="gray"
|
||||
onClick={toggle}
|
||||
>
|
||||
<Avatar
|
||||
size={20}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
|
||||
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 { useDisclosure } from "@mantine/hooks";
|
||||
import ExportModal from "@/components/common/export-modal.tsx";
|
||||
|
||||
interface SpaceDetailsProps {
|
||||
spaceId: string;
|
||||
@ -10,6 +12,8 @@ interface SpaceDetailsProps {
|
||||
}
|
||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -22,6 +26,22 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
|
||||
{!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" />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
@ -34,6 +54,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
|
||||
<DeleteSpaceModal space={space} />
|
||||
</Group>
|
||||
|
||||
<ExportModal
|
||||
type="space"
|
||||
id={space.id}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,56 +1,72 @@
|
||||
import api from '@/lib/api-client';
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IAddSpaceMember,
|
||||
IChangeSpaceMemberRole,
|
||||
IExportSpaceParams,
|
||||
IRemoveSpaceMember,
|
||||
ISpace,
|
||||
} from "@/features/space/types/space.types";
|
||||
import { IPagination, QueryParams } from "@/lib/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);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
spaceId: string
|
||||
): Promise<IPagination<IUser>> {
|
||||
const req = await api.post<any>('/spaces/members', { spaceId });
|
||||
const req = await api.post<any>("/spaces/members", { spaceId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
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(
|
||||
data: IRemoveSpaceMember
|
||||
): Promise<void> {
|
||||
await api.post('/spaces/members/remove', data);
|
||||
await api.post("/spaces/members/remove", data);
|
||||
}
|
||||
|
||||
export async function changeMemberRole(
|
||||
data: IChangeSpaceMemberRole
|
||||
): 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,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
|
||||
export interface ISpace {
|
||||
id: string;
|
||||
@ -68,3 +69,9 @@ export interface 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 = {
|
||||
operation: "invalidate";
|
||||
spaceId: string;
|
||||
entity: Array<string>;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type UpdateEvent = {
|
||||
operation: "updateOne";
|
||||
spaceId: string;
|
||||
entity: Array<string>;
|
||||
id: string;
|
||||
payload: Partial<any>;
|
||||
@ -15,6 +17,7 @@ export type UpdateEvent = {
|
||||
|
||||
export type DeleteEvent = {
|
||||
operation: "deleteOne";
|
||||
spaceId: string;
|
||||
entity: Array<string>;
|
||||
id: string;
|
||||
payload?: Partial<any>;
|
||||
@ -22,6 +25,7 @@ export type DeleteEvent = {
|
||||
|
||||
export type AddTreeNodeEvent = {
|
||||
operation: "addTreeNode";
|
||||
spaceId: string;
|
||||
payload: {
|
||||
parentId: string;
|
||||
index: number;
|
||||
@ -31,6 +35,7 @@ export type AddTreeNodeEvent = {
|
||||
|
||||
export type MoveTreeNodeEvent = {
|
||||
operation: "moveTreeNode";
|
||||
spaceId: string;
|
||||
payload: {
|
||||
id: string;
|
||||
parentId: string;
|
||||
@ -41,6 +46,7 @@ export type MoveTreeNodeEvent = {
|
||||
|
||||
export type DeleteTreeNodeEvent = {
|
||||
operation: "deleteTreeNode";
|
||||
spaceId: string;
|
||||
payload: {
|
||||
node: SpaceTreeNode
|
||||
}
|
||||
|
||||
@ -46,30 +46,34 @@ export const useTreeSocket = () => {
|
||||
break;
|
||||
case 'moveTreeNode':
|
||||
// move node
|
||||
treeApi.move({
|
||||
id: event.payload.id,
|
||||
parentId: event.payload.parentId,
|
||||
index: event.payload.index
|
||||
});
|
||||
if (treeApi.find(event.payload.id)) {
|
||||
treeApi.move({
|
||||
id: event.payload.id,
|
||||
parentId: event.payload.parentId,
|
||||
index: event.payload.index
|
||||
});
|
||||
|
||||
// update node position
|
||||
treeApi.update({
|
||||
id: event.payload.id,
|
||||
changes: {
|
||||
position: event.payload.position,
|
||||
}
|
||||
});
|
||||
// update node position
|
||||
treeApi.update({
|
||||
id: event.payload.id,
|
||||
changes: {
|
||||
position: event.payload.position,
|
||||
}
|
||||
});
|
||||
|
||||
setTreeData(treeApi.data);
|
||||
setTreeData(treeApi.data);
|
||||
}
|
||||
|
||||
break;
|
||||
case "deleteTreeNode":
|
||||
treeApi.drop({ id: event.payload.node.id });
|
||||
setTreeData(treeApi.data);
|
||||
if (treeApi.find(event.payload.node.id)){
|
||||
treeApi.drop({ id: event.payload.node.id });
|
||||
setTreeData(treeApi.data);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pages', event.payload.node.slugId].filter(Boolean),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pages', event.payload.node.slugId].filter(Boolean),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@ -141,7 +141,6 @@ export function useGetInvitationQuery(
|
||||
invitationId: string,
|
||||
): UseQueryResult<any, Error> {
|
||||
return useQuery({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
queryKey: ["invitations", invitationId],
|
||||
queryFn: () => getInvitationById({ invitationId }),
|
||||
enabled: !!invitationId,
|
||||
|
||||
@ -26,14 +26,18 @@ api.interceptors.request.use(
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
// we need the response headers
|
||||
if (response.request.responseURL.includes("/api/pages/export")) {
|
||||
return response;
|
||||
// we need the response headers for these endpoints
|
||||
const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"];
|
||||
if (response.request.responseURL) {
|
||||
const path = new URL(response.request.responseURL)?.pathname;
|
||||
if (path && exemptEndpoints.includes(path)) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
return response.data;
|
||||
@ -72,7 +76,7 @@ api.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function redirectToLogin() {
|
||||
|
||||
@ -57,6 +57,14 @@ export function getFileUrl(src: string) {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
return atom(
|
||||
|
||||
@ -74,4 +74,4 @@ export function decodeBase64ToSvgString(base64Data: string): string {
|
||||
|
||||
export function capitalizeFirstChar(string: string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ export default function Page() {
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
|
||||
@ -5,13 +5,14 @@ import * as path from "path";
|
||||
export const envPath = path.resolve(process.cwd(), "..", "..");
|
||||
|
||||
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 {
|
||||
define: {
|
||||
"process.env": {
|
||||
APP_URL,
|
||||
FILE_UPLOAD_SIZE_LIMIT
|
||||
FILE_UPLOAD_SIZE_LIMIT,
|
||||
DRAWIO_URL
|
||||
},
|
||||
'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',
|
||||
},
|
||||
};
|
||||
34
apps/server/eslint.config.mjs
Normal file
34
apps/server/eslint.config.mjs
Normal file
@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@ -28,43 +28,43 @@
|
||||
"test:e2e": "jest --config test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.637.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.637.0",
|
||||
"@casl/ability": "^6.7.1",
|
||||
"@aws-sdk/client-s3": "^3.701.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.701.0",
|
||||
"@casl/ability": "^6.7.2",
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
"@fastify/multipart": "^8.3.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@nestjs/bullmq": "^10.2.1",
|
||||
"@nestjs/common": "^10.4.1",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^10.4.1",
|
||||
"@nestjs/event-emitter": "^2.0.4",
|
||||
"@nestjs/bullmq": "^10.2.2",
|
||||
"@nestjs/common": "^10.4.9",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.9",
|
||||
"@nestjs/event-emitter": "^2.1.1",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/mapped-types": "^2.0.5",
|
||||
"@nestjs/mapped-types": "^2.0.6",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-fastify": "^10.4.1",
|
||||
"@nestjs/platform-socket.io": "^10.4.1",
|
||||
"@nestjs/platform-fastify": "^10.4.9",
|
||||
"@nestjs/platform-socket.io": "^10.4.9",
|
||||
"@nestjs/terminus": "^10.2.3",
|
||||
"@nestjs/websockets": "^10.4.1",
|
||||
"@react-email/components": "0.0.24",
|
||||
"@react-email/render": "^1.0.1",
|
||||
"@nestjs/websockets": "^10.4.9",
|
||||
"@react-email/components": "0.0.28",
|
||||
"@react-email/render": "^1.0.2",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.12.12",
|
||||
"bullmq": "^5.29.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"fix-esm": "^1.0.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"happy-dom": "^15.7.3",
|
||||
"happy-dom": "^15.11.6",
|
||||
"kysely": "^0.27.4",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"marked": "^13.0.3",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "^5.0.7",
|
||||
"nanoid": "^5.0.9",
|
||||
"nestjs-kysely": "^1.0.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
"nodemailer": "^6.9.16",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.12.0",
|
||||
"pg": "^8.13.1",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"postmark": "^4.0.5",
|
||||
"react": "^18.3.1",
|
||||
@ -72,40 +72,40 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-filename-ts": "^1.0.2",
|
||||
"socket.io": "^4.7.5",
|
||||
"socket.io": "^4.8.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@nestjs/schematics": "^10.1.4",
|
||||
"@nestjs/testing": "^10.4.1",
|
||||
"@eslint/js": "^9.16.0",
|
||||
"@nestjs/cli": "^10.4.8",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.9",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/debounce": "^1.2.4",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/node": "^22.5.2",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pg": "^8.11.8",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.3.0",
|
||||
"eslint": "^9.9.1",
|
||||
"@types/ws": "^8.5.13",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"globals": "^15.13.0",
|
||||
"jest": "^29.7.0",
|
||||
"kysely-codegen": "^0.16.3",
|
||||
"prettier": "^3.3.3",
|
||||
"react-email": "^3.0.1",
|
||||
"kysely-codegen": "^0.17.0",
|
||||
"prettier": "^3.4.1",
|
||||
"react-email": "^3.0.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Controller, Get, Param, Res } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
import { FastifyReply } from "fastify";
|
||||
import { join } from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
@ -9,4 +12,27 @@ export class AppController {
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@Get('/share/:id')
|
||||
getShare(@Res({ passthrough: false}) res: FastifyReply, @Param() params: any): string {
|
||||
const clientDistPath = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'client/dist',
|
||||
);
|
||||
|
||||
if (fs.existsSync(clientDistPath)) {
|
||||
console.log('exists')
|
||||
const indexFilePath = join(clientDistPath, 'index.html');
|
||||
const stream = fs.createReadStream(indexFilePath);
|
||||
|
||||
console.log(params.id)
|
||||
res.type('text/html').send(stream);
|
||||
console.log('found');
|
||||
return;
|
||||
}
|
||||
console.log('end')
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +36,7 @@ export class CollaborationGateway {
|
||||
port: this.redisConfig.port,
|
||||
options: {
|
||||
password: this.redisConfig.password,
|
||||
db: this.redisConfig.db,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
},
|
||||
}),
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import {StarterKit} from '@tiptap/starter-kit';
|
||||
import {TextAlign} from '@tiptap/extension-text-align';
|
||||
import {TaskList} from '@tiptap/extension-task-list';
|
||||
import {TaskItem} from '@tiptap/extension-task-item';
|
||||
import {Underline} from '@tiptap/extension-underline';
|
||||
import {Superscript} from '@tiptap/extension-superscript';
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { TextAlign } from '@tiptap/extension-text-align';
|
||||
import { TaskList } from '@tiptap/extension-task-list';
|
||||
import { TaskItem } from '@tiptap/extension-task-item';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Superscript } from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import {Highlight} from '@tiptap/extension-highlight';
|
||||
import {Typography} from '@tiptap/extension-typography';
|
||||
import {TextStyle} from '@tiptap/extension-text-style';
|
||||
import {Color} from '@tiptap/extension-color';
|
||||
import {Youtube} from '@tiptap/extension-youtube';
|
||||
import { Highlight } from '@tiptap/extension-highlight';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import { Youtube } from '@tiptap/extension-youtube';
|
||||
import Table from '@tiptap/extension-table';
|
||||
import TableHeader from '@tiptap/extension-table-header';
|
||||
import {
|
||||
@ -30,14 +30,15 @@ import {
|
||||
Attachment,
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed
|
||||
Embed,
|
||||
} from '@docmost/editor-ext';
|
||||
import {generateText, JSONContent} from '@tiptap/core';
|
||||
import {generateHTML} from '../common/helpers/prosemirror/html';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||
import {generateJSON} from '@tiptap/html';
|
||||
import { generateJSON } from '@tiptap/html';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
StarterKit.configure({
|
||||
@ -73,7 +74,7 @@ export const tiptapExtensions = [
|
||||
CustomCodeBlock,
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed
|
||||
Embed,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
@ -88,6 +89,10 @@ export function jsonToText(tiptapJson: JSONContent) {
|
||||
return generateText(tiptapJson, tiptapExtensions);
|
||||
}
|
||||
|
||||
export function jsonToNode(tiptapJson: JSONContent) {
|
||||
return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson);
|
||||
}
|
||||
|
||||
export function getPageId(documentName: string) {
|
||||
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 alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
@ -18,15 +18,25 @@ export async function comparePasswordHash(
|
||||
export type RedisConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
db: number;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export function parseRedisUrl(redisUrl: string): RedisConfig {
|
||||
// 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);
|
||||
|
||||
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() {
|
||||
|
||||
@ -52,7 +52,7 @@ export class AttachmentService {
|
||||
// passing attachmentId to allow for updating diagrams
|
||||
// instead of creating new files for each save
|
||||
if (opts?.attachmentId) {
|
||||
let existingAttachment = await this.attachmentRepo.findById(
|
||||
const existingAttachment = await this.attachmentRepo.findById(
|
||||
opts.attachmentId,
|
||||
);
|
||||
if (!existingAttachment) {
|
||||
|
||||
@ -24,7 +24,9 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
|
||||
try {
|
||||
accessToken = JSON.parse(req.cookies?.authTokens)?.accessToken;
|
||||
} catch {}
|
||||
} catch {
|
||||
throw new BadRequestException('Failed to parse access token');
|
||||
}
|
||||
|
||||
return accessToken || this.extractTokenFromHeader(req);
|
||||
},
|
||||
|
||||
@ -5,7 +5,8 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { sql } from 'kysely';
|
||||
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')();
|
||||
|
||||
@Injectable()
|
||||
|
||||
@ -33,13 +33,13 @@ export async function executeWithPagination<O, DB, TB extends keyof DB>(
|
||||
.select((eb) => eb.ref(deferredJoinPrimaryKey).as('primaryKey'))
|
||||
.execute()
|
||||
// @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));
|
||||
|
||||
qb = qb
|
||||
.where((eb) =>
|
||||
primaryKeys.length > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
|
||||
?
|
||||
eb(deferredJoinPrimaryKey, 'in', primaryKeys as any)
|
||||
: eb(sql`1`, '=', 0),
|
||||
)
|
||||
|
||||
@ -160,4 +160,31 @@ export class PageRepo {
|
||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||
).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');
|
||||
}
|
||||
|
||||
getDrawioUrl(): string {
|
||||
return this.configService.get<string>('DRAWIO_URL');
|
||||
}
|
||||
|
||||
isCloud(): boolean {
|
||||
const cloudConfig = this.configService
|
||||
.get<string>('CLOUD', 'false')
|
||||
|
||||
@ -11,14 +11,22 @@ import { plainToInstance } from 'class-transformer';
|
||||
export class EnvironmentVariables {
|
||||
@IsNotEmpty()
|
||||
@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' },
|
||||
)
|
||||
DATABASE_URL: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@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' },
|
||||
)
|
||||
REDIS_URL: string;
|
||||
|
||||
@ -22,5 +22,19 @@ export class ExportPageDto {
|
||||
|
||||
@IsOptional()
|
||||
@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,
|
||||
} from '@nestjs/common';
|
||||
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 { User } from '@docmost/db/types/entity.types';
|
||||
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||
@ -24,9 +24,10 @@ import { FastifyReply } from 'fastify';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import { getExportExtension } from './utils';
|
||||
import { getMimeType } from '../../common/helpers';
|
||||
import * as path from 'path';
|
||||
|
||||
@Controller()
|
||||
export class ImportController {
|
||||
export class ExportController {
|
||||
constructor(
|
||||
private readonly exportService: ExportService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
@ -54,10 +55,28 @@ export class ImportController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const rawContent = await this.exportService.exportPage(dto.format, page);
|
||||
|
||||
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({
|
||||
'Content-Type': getMimeType(fileExt),
|
||||
@ -67,4 +86,34 @@ export class ImportController {
|
||||
|
||||
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 { ExportService } from './export.service';
|
||||
import { ImportController } from './export.controller';
|
||||
import { ExportController } from './export.controller';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule],
|
||||
providers: [ExportService],
|
||||
controllers: [ImportController],
|
||||
controllers: [ExportController],
|
||||
})
|
||||
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 { turndown } from './turndown-utils';
|
||||
import { ExportFormat } from './dto/export-dto';
|
||||
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()
|
||||
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) {
|
||||
const titleNode = {
|
||||
type: 'heading',
|
||||
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) {
|
||||
prosemirrorJson.content.unshift(titleNode);
|
||||
@ -22,7 +51,13 @@ export class ExportService {
|
||||
const pageHtml = jsonToHtml(prosemirrorJson);
|
||||
|
||||
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) {
|
||||
@ -31,4 +66,157 @@ export class ExportService {
|
||||
|
||||
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) {
|
||||
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 { 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) {
|
||||
if (format === ExportFormat.HTML) {
|
||||
@ -10,3 +17,171 @@ export function getExportExtension(format: string) {
|
||||
}
|
||||
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 * as path from 'path';
|
||||
import {
|
||||
htmlToJson,
|
||||
htmlToJson, jsonToText,
|
||||
tiptapExtensions,
|
||||
} from '../../collaboration/collaboration.util';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
@ -72,6 +72,7 @@ export class ImportService {
|
||||
slugId: generateSlugId(),
|
||||
title: pageTitle,
|
||||
content: prosemirrorJson,
|
||||
textContent: jsonToText(prosemirrorJson),
|
||||
ydoc: await this.createYdoc(prosemirrorJson),
|
||||
position: pagePosition,
|
||||
spaceId: spaceId,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { marked } from 'marked';
|
||||
import { calloutExtension } from './callout.marked';
|
||||
import { mathBlockExtension } from './math-block.marked';
|
||||
import { mathInlineExtension } from "./math-inline.marked";
|
||||
|
||||
marked.use({
|
||||
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> {
|
||||
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();
|
||||
|
||||
switch (driver) {
|
||||
case MailOption.SMTP:
|
||||
case MailOption.SMTP: {
|
||||
let auth = undefined;
|
||||
if (
|
||||
environmentService.getSmtpUsername() &&
|
||||
@ -44,9 +44,10 @@ export const mailDriverConfigProvider = {
|
||||
connectionTimeout: 30 * 1000, // 30 seconds
|
||||
auth,
|
||||
secure: environmentService.getSmtpSecure(),
|
||||
ignoreTLS: environmentService.getSmtpIgnoreTLS()
|
||||
ignoreTLS: environmentService.getSmtpIgnoreTLS(),
|
||||
} as SMTPTransport.Options,
|
||||
};
|
||||
}
|
||||
|
||||
case MailOption.Postmark:
|
||||
return {
|
||||
|
||||
@ -15,6 +15,7 @@ import { QueueName } from './constants';
|
||||
host: redisConfig.host,
|
||||
port: redisConfig.port,
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
},
|
||||
defaultJobOptions: {
|
||||
|
||||
@ -33,7 +33,8 @@ export class StaticModule implements OnModuleInit {
|
||||
ENV: this.environmentService.getNodeEnv(),
|
||||
APP_URL: this.environmentService.getAppUrl(),
|
||||
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>`;
|
||||
|
||||
@ -41,7 +41,7 @@ export const storageDriverConfigProvider = {
|
||||
};
|
||||
|
||||
case StorageOption.S3:
|
||||
const s3Config = {
|
||||
{ const s3Config = {
|
||||
driver,
|
||||
config: {
|
||||
region: environmentService.getAwsS3Region(),
|
||||
@ -68,7 +68,7 @@ export const storageDriverConfigProvider = {
|
||||
};
|
||||
}
|
||||
|
||||
return s3Config;
|
||||
return s3Config; }
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown storage driver: ${driver}`);
|
||||
|
||||
@ -24,7 +24,7 @@ async function bootstrap() {
|
||||
},
|
||||
);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.setGlobalPrefix('api', { exclude: ['share/:id']});
|
||||
|
||||
const redisIoAdapter = new WsRedisIoAdapter(app);
|
||||
await redisIoAdapter.connectToRedis();
|
||||
|
||||
@ -9,6 +9,7 @@ import { Server, Socket } from 'socket.io';
|
||||
import { TokenService } from '../core/auth/services/token.service';
|
||||
import { JwtType } from '../core/auth/dto/jwt-payload';
|
||||
import { OnModuleDestroy } from '@nestjs/common';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: { origin: '*' },
|
||||
@ -17,7 +18,10 @@ import { OnModuleDestroy } from '@nestjs/common';
|
||||
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
constructor(private tokenService: TokenService) {}
|
||||
constructor(
|
||||
private tokenService: TokenService,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
||||
try {
|
||||
@ -27,24 +31,43 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
||||
if (token.type !== JwtType.ACCESS) {
|
||||
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) {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('message')
|
||||
handleMessage(client: Socket, data: string): void {
|
||||
client.broadcast.emit('message', data);
|
||||
}
|
||||
handleMessage(client: Socket, data: any): void {
|
||||
const spaceEvents = [
|
||||
'updateOne',
|
||||
'addTreeNode',
|
||||
'moveTreeNode',
|
||||
'deleteTreeNode',
|
||||
];
|
||||
|
||||
@SubscribeMessage('messageToRoom')
|
||||
handleSendMessageToRoom(@MessageBody() message: any) {
|
||||
this.server.to(message?.roomId).emit('messageToRoom', message);
|
||||
if (spaceEvents.includes(data?.operation) && data?.spaceId) {
|
||||
const room = this.getSpaceRoomName(data.spaceId);
|
||||
client.broadcast.to(room).emit('message', data);
|
||||
return;
|
||||
}
|
||||
|
||||
client.broadcast.emit('message', data);
|
||||
}
|
||||
|
||||
@SubscribeMessage('join-room')
|
||||
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')
|
||||
@ -57,4 +80,8 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
||||
this.server.close();
|
||||
}
|
||||
}
|
||||
|
||||
getSpaceRoomName(spaceId: string): string {
|
||||
return `space-${spaceId}`;
|
||||
}
|
||||
}
|
||||
|
||||
94
package.json
94
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "docmost",
|
||||
"homepage": "https://docmost.com",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nx run-many -t build",
|
||||
@ -17,63 +17,65 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@hocuspocus/extension-redis": "^2.13.5",
|
||||
"@hocuspocus/provider": "^2.13.5",
|
||||
"@hocuspocus/server": "^2.13.5",
|
||||
"@hocuspocus/transformer": "^2.13.5",
|
||||
"@hocuspocus/extension-redis": "^2.14.0",
|
||||
"@hocuspocus/provider": "^2.14.0",
|
||||
"@hocuspocus/server": "^2.14.0",
|
||||
"@hocuspocus/transformer": "^2.14.0",
|
||||
"@joplin/turndown": "^4.0.74",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/extension-code-block": "^2.6.6",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.6.6",
|
||||
"@tiptap/extension-collaboration": "^2.6.6",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.6.6",
|
||||
"@tiptap/extension-color": "^2.6.6",
|
||||
"@tiptap/extension-document": "^2.6.6",
|
||||
"@tiptap/extension-heading": "^2.6.6",
|
||||
"@tiptap/extension-highlight": "^2.6.6",
|
||||
"@tiptap/extension-history": "^2.6.6",
|
||||
"@tiptap/extension-image": "^2.6.6",
|
||||
"@tiptap/extension-link": "^2.6.6",
|
||||
"@tiptap/extension-list-item": "^2.6.6",
|
||||
"@tiptap/extension-list-keymap": "^2.6.6",
|
||||
"@tiptap/extension-mention": "^2.6.6",
|
||||
"@tiptap/extension-placeholder": "^2.6.6",
|
||||
"@tiptap/extension-subscript": "^2.6.6",
|
||||
"@tiptap/extension-superscript": "^2.6.6",
|
||||
"@tiptap/extension-table": "^2.6.6",
|
||||
"@tiptap/extension-table-cell": "^2.6.6",
|
||||
"@tiptap/extension-table-header": "^2.6.6",
|
||||
"@tiptap/extension-table-row": "^2.6.6",
|
||||
"@tiptap/extension-task-item": "^2.6.6",
|
||||
"@tiptap/extension-task-list": "^2.6.6",
|
||||
"@tiptap/extension-text": "^2.6.6",
|
||||
"@tiptap/extension-text-align": "^2.6.6",
|
||||
"@tiptap/extension-text-style": "^2.6.6",
|
||||
"@tiptap/extension-typography": "^2.6.6",
|
||||
"@tiptap/extension-underline": "^2.6.6",
|
||||
"@tiptap/extension-youtube": "^2.6.6",
|
||||
"@tiptap/html": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6",
|
||||
"@tiptap/react": "^2.6.6",
|
||||
"@tiptap/starter-kit": "^2.6.6",
|
||||
"@tiptap/suggestion": "^2.6.6",
|
||||
"@tiptap/core": "^2.10.3",
|
||||
"@tiptap/extension-code-block": "^2.10.3",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.10.3",
|
||||
"@tiptap/extension-collaboration": "^2.10.3",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.10.3",
|
||||
"@tiptap/extension-color": "^2.10.3",
|
||||
"@tiptap/extension-document": "^2.10.3",
|
||||
"@tiptap/extension-heading": "^2.10.3",
|
||||
"@tiptap/extension-highlight": "^2.10.3",
|
||||
"@tiptap/extension-history": "^2.10.3",
|
||||
"@tiptap/extension-image": "^2.10.3",
|
||||
"@tiptap/extension-link": "^2.10.3",
|
||||
"@tiptap/extension-list-item": "^2.10.3",
|
||||
"@tiptap/extension-list-keymap": "^2.10.3",
|
||||
"@tiptap/extension-mention": "^2.10.3",
|
||||
"@tiptap/extension-placeholder": "^2.10.3",
|
||||
"@tiptap/extension-subscript": "^2.10.3",
|
||||
"@tiptap/extension-superscript": "^2.10.3",
|
||||
"@tiptap/extension-table": "^2.10.3",
|
||||
"@tiptap/extension-table-cell": "^2.10.3",
|
||||
"@tiptap/extension-table-header": "^2.10.3",
|
||||
"@tiptap/extension-table-row": "^2.10.3",
|
||||
"@tiptap/extension-task-item": "^2.10.3",
|
||||
"@tiptap/extension-task-list": "^2.10.3",
|
||||
"@tiptap/extension-text": "^2.10.3",
|
||||
"@tiptap/extension-text-align": "^2.10.3",
|
||||
"@tiptap/extension-text-style": "^2.10.3",
|
||||
"@tiptap/extension-typography": "^2.10.3",
|
||||
"@tiptap/extension-underline": "^2.10.3",
|
||||
"@tiptap/extension-youtube": "^2.10.3",
|
||||
"@tiptap/html": "^2.10.3",
|
||||
"@tiptap/pm": "^2.10.3",
|
||||
"@tiptap/react": "^2.10.3",
|
||||
"@tiptap/starter-kit": "^2.10.3",
|
||||
"@tiptap/suggestion": "^2.10.3",
|
||||
"bytes": "^3.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"dompurify": "^3.2.1",
|
||||
"fractional-indexing-jittered": "^0.9.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"uuid": "^10.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"uuid": "^11.0.3",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.18"
|
||||
"yjs": "^13.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nx/js": "19.6.3",
|
||||
"@nx/js": "20.1.3",
|
||||
"@types/bytes": "^3.1.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"nx": "19.6.3",
|
||||
"tsx": "^4.19.0"
|
||||
"concurrently": "^9.1.0",
|
||||
"nx": "20.1.3",
|
||||
"tsx": "^4.19.2"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@ -7,6 +7,8 @@ export interface CustomCodeBlockOptions extends CodeBlockLowlightOptions {
|
||||
view: any;
|
||||
}
|
||||
|
||||
const TAB_CHAR = "\u00A0\u00A0";
|
||||
|
||||
export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
|
||||
{
|
||||
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() {
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import TiptapLink from "@tiptap/extension-link";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
@ -5,6 +6,24 @@ import { EditorView } from "@tiptap/pm/view";
|
||||
export const LinkExtension = TiptapLink.extend({
|
||||
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() {
|
||||
const { editor } = this;
|
||||
|
||||
|
||||
7232
pnpm-lock.yaml
generated
7232
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user