mirror of
https://github.com/docmost/docmost.git
synced 2025-11-11 07:42:05 +10:00
Compare commits
117 Commits
v0.2.4
...
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 | |||
| 95159625aa | |||
| 9e0fbae1de | |||
| a52c86a180 | |||
| 31feb38def | |||
| ba32e42ece | |||
| a574d13f43 | |||
| ab70cee278 | |||
| 978fadd6b9 | |||
| b57be9c736 | |||
| d4b219d608 | |||
| 36e720920b | |||
| fa3c8a03e1 | |||
| 46d92fbabc | |||
| e17b975aaa | |||
| 038d21b438 | |||
| 078361b367 | |||
| 384f11f2b7 | |||
| e333eee08b | |||
| 7ec6a36515 | |||
| 2721ab6a29 | |||
| a2bc374f47 | |||
| eaa80a5546 | |||
| e9e668bd39 | |||
| 9390b39e35 | |||
| 2ae3816324 | |||
| e96330afbf | |||
| e56f7933f4 | |||
| b152c858b4 | |||
| e43ea66442 | |||
| f34812653e | |||
| 6a3a7721be | |||
| fb27282886 | |||
| dea9f4c063 | |||
| 0b6730c06f | |||
| be0d97661a | |||
| 4e2b23c97e | |||
| dc3ce27762 | |||
| 8af2d4e8cf | |||
| 73ddec4ca7 | |||
| 2b9765fb35 | |||
| 7fdd355cc3 | |||
| 6c6b47599a | |||
| 7e6a71fa2d | |||
| 1141796f24 | |||
| 11dbc079be | |||
| 87b99f8646 | |||
| 38e9eef2dc | |||
| 77b541ec71 | |||
| 7dc37b933f | |||
| 7e80797e3f | |||
| 17475bf123 | |||
| 4433d5174d | |||
| c810d0b314 | |||
| 463480ae67 | |||
| 2449d69fab | |||
| e0d74fcb0e | |||
| 4967849e3a | |||
| 0a447e91bb | |||
| 48e76aa9f4 | |||
| 2bd6422a35 | |||
| 407a1aff3b | |||
| b4bc184cb3 | |||
| 109dbdbe02 | |||
| 2df7de5828 | |||
| 373fc86e47 | |||
| 5052a9ea40 | |||
| cd47c79d86 | |||
| 78746938b7 | |||
| 4d2936627c | |||
| d2ecd28047 | |||
| bb92ca75e9 | |||
| 8f3e2ff663 | |||
| 89f6311e46 | |||
| e5a97d2a26 | |||
| 7f0fd45f3a | |||
| 078959dfa0 | |||
| 937a07059a | |||
| 227ac30d5e | |||
| a2ae341934 | |||
| 3c70e40d16 | |||
| 14197d7365 | |||
| f388540293 |
@ -19,6 +19,10 @@ AWS_S3_SECRET_ACCESS_KEY=
|
||||
AWS_S3_REGION=
|
||||
AWS_S3_BUCKET=
|
||||
AWS_S3_ENDPOINT=
|
||||
AWS_S3_FORCE_PATH_STYLE=
|
||||
|
||||
# default: 50mb
|
||||
FILE_UPLOAD_SIZE_LIMIT=
|
||||
|
||||
# options: smtp | postmark
|
||||
MAIL_DRIVER=smtp
|
||||
@ -30,7 +34,11 @@ SMTP_HOST=127.0.0.1
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SECURE=false
|
||||
SMTP_IGNORETLS=false
|
||||
|
||||
# Postmark driver config
|
||||
POSTMARK_TOKEN=
|
||||
|
||||
# for custom drawio server
|
||||
DRAWIO_URL=
|
||||
@ -1,4 +1,5 @@
|
||||
FROM node:21-alpine AS base
|
||||
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
@ -29,6 +30,9 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
COPY --from=builder /app/pnpm*.yaml /app/
|
||||
|
||||
# Copy patches
|
||||
COPY --from=builder /app/patches /app/patches
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
@ -17,6 +17,7 @@ To get started with Docmost, please refer to our [documentation](https://docmost
|
||||
|
||||
## Features
|
||||
- Real-time collaboration
|
||||
- Diagrams (Draw.io, Excalidraw and Mermaid)
|
||||
- Spaces
|
||||
- Permissions management
|
||||
- Groups
|
||||
@ -32,4 +33,4 @@ To get started with Docmost, please refer to our [documentation](https://docmost
|
||||
</p>
|
||||
|
||||
### Contributing
|
||||
See the [development doc](https://docmost.com/docs/self-hosting/development)
|
||||
See the [development documentation](https://docmost.com/docs/self-hosting/development)
|
||||
|
||||
@ -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,67 +1,76 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.2.4",
|
||||
"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",
|
||||
"@mantine/core": "^7.11.0",
|
||||
"@mantine/form": "^7.11.0",
|
||||
"@mantine/hooks": "^7.11.0",
|
||||
"@mantine/modals": "^7.11.0",
|
||||
"@mantine/notifications": "^7.11.0",
|
||||
"@mantine/spotlight": "^7.11.0",
|
||||
"@tabler/icons-react": "^3.7.0",
|
||||
"@tanstack/react-query": "^5.48.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.4.0",
|
||||
"axios": "^1.7.2",
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@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",
|
||||
"jotai": "^2.8.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"jotai": "^2.10.3",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.10",
|
||||
"lowlight": "^3.1.0",
|
||||
"katex": "^0.16.11",
|
||||
"lowlight": "^3.2.0",
|
||||
"mermaid": "^11.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "^3.4.0",
|
||||
"react-clear-modal": "^2.0.11",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-drawio": "^1.0.1",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-moveable": "^0.56.0",
|
||||
"react-router-dom": "^6.24.0",
|
||||
"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.16",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/eslint-plugin-query": "^5.47.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": "20.14.9",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.14.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^9.5.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"@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.38",
|
||||
"postcss-preset-mantine": "^1.15.0",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.3.2",
|
||||
"typescript": "^5.5.2",
|
||||
"vite": "^5.3.1"
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,8 @@ import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||
import Layout from "@/components/layouts/global/layout.tsx";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||
import PasswordReset from "./pages/auth/password-reset";
|
||||
|
||||
export default function App() {
|
||||
const [, setSocket] = useAtom(socketAtom);
|
||||
@ -60,9 +62,18 @@ 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 />} />
|
||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||
|
||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||
|
||||
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -4,24 +4,25 @@ import {
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ScrollArea,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
ActionIcon,
|
||||
} from '@mantine/core';
|
||||
import {Link} from 'react-router-dom';
|
||||
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
|
||||
import {buildPageUrl} from '@/features/page/page.utils.ts';
|
||||
import {formattedDate} from '@/lib/time.ts';
|
||||
import {useRecentChangesQuery} from '@/features/page/queries/page-query.ts';
|
||||
import {IconFileDescription} from '@tabler/icons-react';
|
||||
import {getSpaceUrl} from '@/lib/config.ts';
|
||||
|
||||
interface Props {
|
||||
spaceId?: string;
|
||||
}
|
||||
export default function RecentChanges({ spaceId }: Props) {
|
||||
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
|
||||
|
||||
export default function RecentChanges({spaceId}: Props) {
|
||||
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton />;
|
||||
return <PageListSkeleton/>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
@ -29,7 +30,7 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
}
|
||||
|
||||
return pages && pages.items.length > 0 ? (
|
||||
<ScrollArea>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.items.map((page) => (
|
||||
@ -40,10 +41,14 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || <IconFileDescription size={18} />}
|
||||
{page.icon || (
|
||||
<ActionIcon variant='transparent' color='gray' size={18}>
|
||||
<IconFileDescription size={18}/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || "Untitled"}
|
||||
{page.title || 'Untitled'}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
@ -55,14 +60,14 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
style={{cursor: 'pointer'}}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text c="dimmed" size="xs" fw={500}>
|
||||
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
@ -70,7 +75,7 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Table.ScrollContainer>
|
||||
) : (
|
||||
<Text size="md" ta="center">
|
||||
No pages yet
|
||||
|
||||
32
apps/client/src/components/icons/airtable-icon.tsx
Normal file
32
apps/client/src/components/icons/airtable-icon.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function AirtableIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 215"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
fill="#ffbf00"
|
||||
d="M114.259 2.701 18.86 42.176c-5.305 2.195-5.25 9.73.089 11.847l95.797 37.989a35.544 35.544 0 0 0 26.208 0l95.799-37.99c5.337-2.115 5.393-9.65.086-11.846L141.442 2.7a35.549 35.549 0 0 0-27.183 0"
|
||||
/>
|
||||
<path
|
||||
fill="#26b5f8"
|
||||
d="M136.35 112.757v94.902c0 4.514 4.55 7.605 8.746 5.942l106.748-41.435a6.39 6.39 0 0 0 4.035-5.941V71.322c0-4.514-4.551-7.604-8.747-5.941l-106.748 41.434a6.392 6.392 0 0 0-4.035 5.942"
|
||||
/>
|
||||
<path
|
||||
fill="#ed3049"
|
||||
d="m111.423 117.654-31.68 15.296-3.217 1.555L9.65 166.548C5.411 168.593 0 165.504 0 160.795V71.72c0-1.704.874-3.175 2.046-4.283a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
|
||||
/>
|
||||
<path
|
||||
fillOpacity={0.25}
|
||||
d="m111.423 117.654-31.68 15.296L2.045 67.438a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
23
apps/client/src/components/icons/figma-icon.tsx
Normal file
23
apps/client/src/components/icons/figma-icon.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function FigmaIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<g fill="none" fillRule="evenodd" transform="translate(4)">
|
||||
<circle cx={12} cy={12} r={4} fill="#19bcfe" />
|
||||
<path fill="#09cf83" d="M4 24a4 4 0 0 0 4-4v-4H4a4 4 0 1 0 0 8z" />
|
||||
<path fill="#a259ff" d="M4 16h4V8H4a4 4 0 1 0 0 8z" />
|
||||
<path fill="#f24e1e" d="M4 8h4V0H4a4 4 0 1 0 0 8z" />
|
||||
<path fill="#ff7262" d="M12 8H8V0h4a4 4 0 1 1 0 8z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
17
apps/client/src/components/icons/framer-icon.tsx
Normal file
17
apps/client/src/components/icons/framer-icon.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function FramerIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M4 0h16v8h-8zm0 8h8l8 8H4zm0 8h8v8z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
24
apps/client/src/components/icons/google-drive-icon.tsx
Normal file
24
apps/client/src/components/icons/google-drive-icon.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function GoogleDriveIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 87.3 78"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da" />
|
||||
<path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47" />
|
||||
<path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
|
||||
fill="#ea4335" />
|
||||
<path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d" />
|
||||
<path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc" />
|
||||
<path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
|
||||
fill="#ffba00" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
apps/client/src/components/icons/icon-drawio.tsx
Normal file
20
apps/client/src/components/icons/icon-drawio.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
function IconDrawio({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#F08705"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M19.69 13.419h-2.527l-2.667-4.555a1.292 1.292 0 001.035-1.28V4.16c0-.725-.576-1.312-1.302-1.312H9.771c-.726 0-1.312.576-1.312 1.301v3.435c0 .619.426 1.152 1.034 1.28l-2.666 4.555H4.309c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312h4.458c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312h-.437l2.645-4.523h2.059l2.656 4.523h-.438c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312H19.7c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312zM24 22.976c0 .565-.459 1.024-1.013 1.024H1.024A1.022 1.022 0 010 22.987V1.024C0 .459.459 0 1.013 0h21.963C23.541 0 24 .459 24 1.013z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconDrawio;
|
||||
20
apps/client/src/components/icons/icon-excalidraw.tsx
Normal file
20
apps/client/src/components/icons/icon-excalidraw.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
function IconExcalidraw({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="#6965DB"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M23.943 19.806a.196.196 0 00-.168-.034c-1.26-1.855-2.873-3.61-4.419-5.315l-.252-.284c-.001-.073-.067-.12-.134-.15l-.084-.084c-.05-.1-.169-.167-.286-.1-.47.234-.907.585-1.327.919-.554.434-1.109.87-1.63 1.354a5.058 5.058 0 00-.588.618c-.084.117-.017.217.084.267-.37.368-.74.736-1.109 1.12a.19.19 0 00-.05.134c0 .05.033.1.067.117l.655.502v.016c.924.92 2.554 2.173 4.285 3.527.251.201.52.402.773.602.117.134.234.285.335.418.05.066.169.084.236.033.033.034.084.067.118.1a.24.24 0 00.1.034.153.153 0 00.135-.066.237.237 0 00.033-.1c.017 0 .017.016.034.016a.192.192 0 00.134-.05l3.058-3.327c.12-.116.014-.267 0-.267zm-7.628-.134l-1.546-1.17-.15-.1c-.035-.017-.068-.05-.102-.067l-.117-.1c.66-.66 1.33-1.308 2-1.956-.488.484-1.463 1.906-1.261 2.373.002 0 .018.042.067.084l1.11.936zm4.1 3.126l-1.277-.97a26.906 26.906 0 00-1.58-1.504c.69.518 1.277.97 1.361 1.053.673.585.638.485 1.093.87l.554.4c-.074.103-.151.148-.151.151zm.336.25l-.034-.016a.913.913 0 00.152-.117zM.587 3.476c.034.217.085.435.118.636.201 1.103.403 2.106.772 2.858l.152.568c.05.217.134.485.219.552a66.769 66.769 0 003.578 2.942.177.177 0 00.219 0s0 .016.016.016a.153.153 0 00.118.05.191.191 0 00.134-.05c1.798-1.989 3.142-3.627 4.1-4.998.068-.066.084-.167.084-.25.067-.067.118-.151.185-.201.067-.067.067-.184 0-.235l-.017-.016c0-.033-.017-.084-.05-.1-.42-.401-.722-.685-1.042-.986a93.555 93.555 0 01-2.352-2.273c-.017-.017-.034-.034-.067-.034-.336-.117-1.025-.234-1.882-.385-1.277-.216-3.008-.517-4.57-.986 0 0-.101 0-.118.017l-.05.05C.05.714.022.707 0 .718c.017.1.017.167.05.284 0 .033.068.301.068.334zm7.191 4.78l-.033.034a.036.036 0 01.033-.034zM6.553 2.238c.101.1.521.502.622.585-.437-.2-1.529-.702-2.034-.869.505.1 1.194.201 1.412.284zM.79 1.403c.252.434.454 1.939.655 3.41-.118-.469-.201-.936-.302-1.372C.992 2.673.84 1.988.638 1.386c.124 0 .152.021.152.017zm-.286-.369c0-.016 0-.033-.017-.033.085 0 .135.017.202.05 0 .006-.145-.017-.185-.017zm23.17-.217c.017-.066-.336-.367-.219-.384.253-.017.253-.401 0-.401-.335.017-.688.1-1.008.15-.587.117-1.192.234-1.78.367a79.696 79.696 0 00-3.949.937c-.403.117-.857.2-1.243.401-.135.067-.118.2-.05.284-.034.017-.051.017-.085.034-.117.017-.218.034-.335.05-.102.017-.152.1-.135.2 0 .017.017.05.017.067-.706.936-1.496 1.923-2.353 2.976-.84.969-1.73 1.989-2.62 3.042-2.84 3.31-6.05 7.07-9.594 10.38a.161.161 0 000 .234c.016.016.033.033.05.033-.05.05-.101.085-.152.134-.033.034-.05.067-.05.1a.364.364 0 00-.067.084c-.067.067-.067.184.017.234.067.066.185.066.235-.017.017-.017.017-.033.033-.033a.265.265 0 01.37 0c.202.217.404.435.588.618l-.42-.35c-.067-.067-.184-.05-.234.016-.068.066-.051.184.016.234l4.469 3.727c.034.034.067.034.118.034a.15.15 0 00.117-.05l.101-.1c.017.016.05.016.067.016.05 0 .084-.016.118-.05 6.049-6.05 10.922-10.614 16.5-14.693.05-.033.067-.1.067-.15.067 0 .118-.05.15-.117 1.026-3.125 1.228-5.9 1.295-7.27 0-.059.016-.038.016-.068.017-.033.017-.05.017-.05a.978.978 0 00-.067-.619zm-10.82 4.915c.268-.301.537-.619.806-.903-1.73 2.273-4.603 5.766-8.67 9.929 2.773-3.059 5.562-6.218 7.864-9.026zM5.14 23.466c-.016-.017-.016-.017 0-.017zm2.504-2.156c.135-.15.27-.284.42-.434 0 0 0 .016.017.016-.224.198-.433.418-.437.418zm.69-.668c.099-.1.14-.173.284-.318.992-1.02 2.017-2.04 3.059-3.076l.016-.016c.252-.2.555-.418.824-.619a228.063 228.063 0 00-4.184 4.029zM14.852 3.91c-.554.719-1.176 1.671-1.697 2.423-1.646 2.374-6.94 8.174-7.057 8.274a1189.647 1189.647 0 01-4.839 4.597l-.1.1c-.085-.1-.085-.25.016-.334C8.652 11.966 13.19 6.133 15.021 3.576c-.05.116-.084.216-.168.334zm2.906 3.427c-.671-.386-.99-.987-.806-1.572l.05-.2a.775.775 0 01.085-.167 1.9 1.9 0 01.756-.703c.016 0 .033 0 .05-.016-.017-.034-.017-.084-.017-.134.017-.1.085-.167.202-.167.202 0 .824.184 1.059.384.067.05.134.117.202.184.084.1.218.268.285.401.034.017.067.184.118.268.033.134.067.284.05.418-.017.016 0 .116-.017.116a1.605 1.605 0 01-.218.619c-.03.03.006.012-.05.067a1.22 1.22 0 01-.32.334 1.49 1.49 0 01-1.26.234 2.191 2.191 0 00-.169-.066zm4.37 1.403c0 .017-.017.05 0 .067-.034 0-.05.017-.085.034a109.886 109.886 0 00-3.915 3.025c1.11-.986 2.218-1.989 3.378-2.975.336-.301.571-.686.638-1.12l.168-1.003v-.033c.085-.201.404-.118.353.1-.004-.001-.173.795-.537 1.905z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconExcalidraw;
|
||||
20
apps/client/src/components/icons/icon-mermaid.tsx
Normal file
20
apps/client/src/components/icons/icon-mermaid.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
function IconMermaid({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="#FF3670"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M23.99 2.115A12.223 12.223 0 0012 10.149 12.223 12.223 0 00.01 2.115a12.23 12.23 0 005.32 10.604 6.562 6.562 0 012.845 5.423v3.754h7.65v-3.754a6.561 6.561 0 012.844-5.423 12.223 12.223 0 005.32-10.604z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconMermaid;
|
||||
10
apps/client/src/components/icons/index.ts
Normal file
10
apps/client/src/components/icons/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export { AirtableIcon } from "./airtable-icon.tsx";
|
||||
export { FigmaIcon } from "./figma-icon.tsx";
|
||||
export { TypeformIcon } from "./typeform-icon.tsx";
|
||||
export { VimeoIcon } from "./vimeo-icon.tsx";
|
||||
export { MiroIcon } from "./miro-icon.tsx";
|
||||
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
|
||||
export { FramerIcon } from "./framer-icon.tsx";
|
||||
export { LoomIcon } from "./loom-icon.tsx";
|
||||
export { YoutubeIcon } from "./youtube-icon.tsx";
|
||||
|
||||
19
apps/client/src/components/icons/loom-icon.tsx
Normal file
19
apps/client/src/components/icons/loom-icon.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function LoomIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#625DF5"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M24 10.665h-7.018l6.078-3.509-1.335-2.312-6.078 3.509 3.508-6.077L16.843.94l-3.508 6.077V0h-2.67v7.018L7.156.94 4.844 2.275l3.509 6.077-6.078-3.508L.94 7.156l6.078 3.509H0v2.67h7.017L.94 16.844l1.335 2.313 6.077-3.508-3.509 6.077 2.312 1.335 3.509-6.078V24h2.67v-7.017l3.508 6.077 2.312-1.335-3.509-6.078 6.078 3.509 1.335-2.313-6.077-3.508h7.017v-2.67H24zm-12 4.966a3.645 3.645 0 1 1 0-7.29 3.645 3.645 0 0 1 0 7.29z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
apps/client/src/components/icons/miro-icon.tsx
Normal file
18
apps/client/src/components/icons/miro-icon.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function MiroIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
apps/client/src/components/icons/typeform-icon.tsx
Normal file
18
apps/client/src/components/icons/typeform-icon.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function TypeformIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M15.502 13.035c-.5 0-.756-.411-.756-.917 0-.505.252-.894.756-.894.513 0 .756.407.756.894-.004.515-.261.917-.756.917Zm-4.888-1.81c.292 0 .414.17.414.317 0 .357-.365.514-1.126.536 0-.442.253-.854.712-.854Zm-3.241 1.81c-.473 0-.67-.384-.67-.917 0-.527.202-.894.67-.894.477 0 .702.38.702.894 0 .537-.234.917-.702.917Zm-3.997-2.334h-.738l1.224 2.808c-.234.519-.36.648-.522.648-.171 0-.333-.138-.45-.259l-.324.43c.22.232.522.366.832.366.387 0 .685-.224.856-.626l1.413-3.371h-.725l-.738 2.012-.828-2.008Zm19.553.523c.36 0 .432.246.432.823v1.516H24v-1.914c0-.689-.473-.988-.91-.988-.386 0-.742.241-.94.688a.901.901 0 0 0-.891-.688c-.365 0-.73.232-.927.666v-.626h-.64v2.857h.64v-1.22c0-.617.324-1.114.765-1.114.36 0 .427.246.427.823v1.516h.64l-.005-1.225c0-.617.329-1.114.77-1.114Zm-5.1-.523h-.324v2.857h.639v-1.095c0-.693.306-1.163.76-1.163.118 0 .217.005.325.05l.099-.676c-.081-.009-.153-.018-.225-.018-.45 0-.774.309-.964.707V10.7h-.31Zm-2.327-.045c-.846 0-1.418.644-1.418 1.458 0 .845.58 1.475 1.418 1.475.85 0 1.431-.648 1.431-1.475-.004-.818-.594-1.458-1.431-1.458Zm-4.852 2.38c-.333 0-.581-.17-.685-.515.847-.036 1.675-.242 1.675-.988 0-.43-.423-.872-1.03-.872-.82 0-1.374.666-1.374 1.457 0 .828.545 1.476 1.36 1.476.567 0 .927-.228 1.21-.559l-.31-.42c-.329.335-.531.42-.846.42Zm-3.151-2.38c-.324 0-.648.188-.774.483v-.438h-.64v3.98h.64v-1.422c.135.205.445.34.72.34.85 0 1.3-.631 1.3-1.48-.004-.841-.445-1.463-1.246-1.463Zm-4.483-1.1H0v.622h1.18v3.38h.67v-3.38h1.166v-.622Zm9.502 1.145h-.383v.572h.383v2.285h.639v-2.285h.621v-.572h-.621v-.447c0-.286.117-.385.382-.385.1 0 .19.027.311.068l.144-.537c-.117-.067-.351-.094-.504-.094-.612 0-.972.367-.972 1.002v.393Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
apps/client/src/components/icons/vimeo-icon.tsx
Normal file
19
apps/client/src/components/icons/vimeo-icon.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function VimeoIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#1AB7EA"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M23.9765 6.4168c-.105 2.338-1.739 5.5429-4.894 9.6088-3.2679 4.247-6.0258 6.3699-8.2898 6.3699-1.409 0-2.578-1.294-3.553-3.881l-1.9179-7.1138c-.719-2.584-1.488-3.878-2.312-3.878-.179 0-.806.378-1.8809 1.132l-1.129-1.457a315.06 315.06 0 003.501-3.1279c1.579-1.368 2.765-2.085 3.5539-2.159 1.867-.18 3.016 1.1 3.447 3.838.465 2.953.789 4.789.971 5.5069.5389 2.45 1.1309 3.674 1.7759 3.674.502 0 1.256-.796 2.265-2.385 1.004-1.589 1.54-2.797 1.612-3.628.144-1.371-.395-2.061-1.614-2.061-.574 0-1.167.121-1.777.391 1.186-3.8679 3.434-5.7568 6.7619-5.6368 2.4729.06 3.6279 1.664 3.4929 4.7969z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
apps/client/src/components/icons/youtube-icon.tsx
Normal file
19
apps/client/src/components/icons/youtube-icon.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function YoutubeIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#FF0000"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -1,18 +1,18 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import {Group, Text, Tooltip} from "@mantine/core";
|
||||
import classes from "./app-header.module.css";
|
||||
import React from "react";
|
||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import {Link} from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useAtom } from "jotai/index";
|
||||
import {useAtom} from "jotai/index";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import {useToggleSidebar} from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||
|
||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||
const links = [{link: APP_ROUTE.HOME, label: "Home"}];
|
||||
|
||||
export function AppHeader() {
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
@ -35,28 +35,35 @@ export function AppHeader() {
|
||||
<Group wrap="nowrap">
|
||||
{!isHomeRoute && (
|
||||
<>
|
||||
<SidebarToggle
|
||||
aria-label="sidebar toggle"
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<Tooltip label="Sidebar toggle">
|
||||
|
||||
<SidebarToggle
|
||||
aria-label="sidebar toggle"
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<SidebarToggle
|
||||
aria-label="Sidebar toggle"
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Sidebar toggle">
|
||||
<SidebarToggle
|
||||
aria-label="Sidebar toggle"
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
style={{ cursor: "pointer", userSelect: "none" }}
|
||||
style={{cursor: "pointer", userSelect: "none"}}
|
||||
component={Link}
|
||||
to="/home"
|
||||
>
|
||||
Docmost
|
||||
</Text>
|
||||
@ -67,7 +74,7 @@ export function AppHeader() {
|
||||
</Group>
|
||||
|
||||
<Group px={"xl"}>
|
||||
<TopMenu />
|
||||
<TopMenu/>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -73,11 +73,11 @@ export default function TopMenu() {
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div style={{width: 190}}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@ -93,6 +93,18 @@ export default function SettingsSidebar() {
|
||||
</Group>
|
||||
|
||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||
<div className={classes.version}>
|
||||
<Text
|
||||
className={classes.version}
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component="a"
|
||||
href="https://github.com/docmost/docmost/releases"
|
||||
target="_blank"
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -57,3 +57,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.version {
|
||||
padding-left: var(--mantine-spacing-xs) ;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import data from "@emoji-mart/data";
|
||||
import Picker from "@emoji-mart/react";
|
||||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Popover,
|
||||
Button,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const Picker = React.lazy(() => import('@emoji-mart/react'));
|
||||
|
||||
export interface EmojiPickerInterface {
|
||||
onEmojiSelect: (emoji: any) => void;
|
||||
@ -48,23 +49,25 @@ function EmojiPicker({
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown bg="000" style={{ border: "none" }}>
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
perLine={8}
|
||||
skinTonePosition="search"
|
||||
theme={colorScheme}
|
||||
/>
|
||||
<Popover.Dropdown bg="000" style={{ border: 'none' }}>
|
||||
<Suspense fallback={null}>
|
||||
<Picker
|
||||
data={async () => (await import('@emoji-mart/data')).default}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
perLine={8}
|
||||
skinTonePosition="search"
|
||||
theme={colorScheme}
|
||||
/>
|
||||
</Suspense>
|
||||
<Button
|
||||
variant="default"
|
||||
c="gray"
|
||||
size="xs"
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
zIndex: 2,
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
bottom: '1rem',
|
||||
right: '1rem',
|
||||
}}
|
||||
onClick={handleRemoveEmoji}
|
||||
>
|
||||
|
||||
@ -1,19 +1,25 @@
|
||||
import { Title, Text, Button, Container, Group } from "@mantine/core";
|
||||
import classes from "./error-404.module.css";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
export function Error404() {
|
||||
return (
|
||||
<Container className={classes.root}>
|
||||
<Title className={classes.title}>404 Page Not Found</Title>
|
||||
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
|
||||
Sorry, we can't find the page you are looking for.
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button component={Link} to={"/home"} variant="subtle" size="md">
|
||||
Take me back to homepage
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
<>
|
||||
<Helmet>
|
||||
<title>404 page not found - Docmost</title>
|
||||
</Helmet>
|
||||
<Container className={classes.root}>
|
||||
<Title className={classes.title}>404 Page Not Found</Title>
|
||||
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
|
||||
Sorry, we can't find the page you are looking for.
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button component={Link} to={"/home"} variant="subtle" size="md">
|
||||
Take me back to homepage
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ const RoleButton = forwardRef<HTMLButtonElement, RoleButtonProps>(
|
||||
),
|
||||
);
|
||||
|
||||
interface SpaceRoleMenuProps {
|
||||
interface RoleMenuProps {
|
||||
roles: IRoleData[];
|
||||
roleName: string;
|
||||
onChange?: (value: string) => void;
|
||||
@ -35,7 +35,7 @@ export default function RoleSelectMenu({
|
||||
roleName,
|
||||
onChange,
|
||||
disabled,
|
||||
}: SpaceRoleMenuProps) {
|
||||
}: RoleMenuProps) {
|
||||
return (
|
||||
<Menu withArrow>
|
||||
<Menu.Target>
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
import React from "react";
|
||||
import {
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconLayoutSidebarRightExpand,
|
||||
IconLayoutSidebarRightExpand
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
BoxProps,
|
||||
ElementProps,
|
||||
MantineColor,
|
||||
MantineSize,
|
||||
} from "@mantine/core";
|
||||
import React from "react";
|
||||
import { ActionIcon, BoxProps, ElementProps, MantineColor, MantineSize } from "@mantine/core";
|
||||
|
||||
export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
|
||||
size?: MantineSize | `compact-${MantineSize}` | (string & {});
|
||||
@ -17,18 +11,18 @@ export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
|
||||
opened?: boolean;
|
||||
}
|
||||
|
||||
export default function SidebarToggle({
|
||||
opened,
|
||||
size = "sm",
|
||||
...others
|
||||
}: SidebarToggleProps) {
|
||||
return (
|
||||
<ActionIcon size={size} {...others} variant="subtle" color="gray">
|
||||
{opened ? (
|
||||
<IconLayoutSidebarRightExpand />
|
||||
) : (
|
||||
<IconLayoutSidebarRightCollapse />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
|
||||
({ opened, size = "sm", ...others }, ref) => {
|
||||
return (
|
||||
<ActionIcon size={size} {...others} variant="subtle" color="gray" ref={ref}>
|
||||
{opened ? (
|
||||
<IconLayoutSidebarRightExpand />
|
||||
) : (
|
||||
<IconLayoutSidebarRightCollapse />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default SidebarToggle;
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { createJSONStorage, atomWithStorage } from "jotai/utils";
|
||||
import { ITokens } from '../types/auth.types';
|
||||
|
||||
import { ITokens } from "../types/auth.types";
|
||||
|
||||
const cookieStorage = createJSONStorage<ITokens>(() => {
|
||||
return {
|
||||
getItem: () => Cookies.get("authTokens"),
|
||||
setItem: (key, value) => Cookies.set(key, value),
|
||||
setItem: (key, value) => Cookies.set(key, value, { expires: 30 }),
|
||||
removeItem: (key) => Cookies.remove(key),
|
||||
};
|
||||
});
|
||||
|
||||
export const authTokensAtom = atomWithStorage<ITokens | null>("authTokens", null, cookieStorage);
|
||||
export const authTokensAtom = atomWithStorage<ITokens | null>(
|
||||
"authTokens",
|
||||
null,
|
||||
cookieStorage,
|
||||
);
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import * as z from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import { IForgotPassword } from "@/features/auth/types/auth.types";
|
||||
import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
|
||||
import classes from "./auth.module.css";
|
||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: "Email is required" })
|
||||
.email({ message: "Invalid email address" }),
|
||||
});
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
const { forgotPassword, isLoading } = useAuth();
|
||||
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
|
||||
useRedirectIfAuthenticated();
|
||||
|
||||
const form = useForm<IForgotPassword>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: IForgotPassword) {
|
||||
if (await forgotPassword(data)) {
|
||||
setIsTokenSent(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} my={40} className={classes.container}>
|
||||
<Box p="xl" mt={200}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
Forgot password
|
||||
</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
{!isTokenSent && (
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="email@example.com"
|
||||
variant="filled"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isTokenSent && (
|
||||
<Text>
|
||||
A password reset link has been sent to your email. Please check
|
||||
your inbox.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isTokenSent && (
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
Send reset link
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@ -19,7 +19,7 @@ import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-qu
|
||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
name: z.string().trim().min(1),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import * as React from "react";
|
||||
import * as z from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
@ -10,9 +9,13 @@ import {
|
||||
Button,
|
||||
PasswordInput,
|
||||
Box,
|
||||
|
||||
Anchor,
|
||||
} from "@mantine/core";
|
||||
import classes from "./auth.module.css";
|
||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z
|
||||
@ -62,10 +65,20 @@ export function LoginForm() {
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Anchor
|
||||
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
|
||||
component={Link}
|
||||
underline="never"
|
||||
size="sm"
|
||||
>
|
||||
Forgot your password?
|
||||
</Anchor>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
import * as z from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import { IPasswordReset } from "@/features/auth/types/auth.types";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
PasswordInput,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import classes from "./auth.module.css";
|
||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, { message: "Password must contain at least 8 characters" }),
|
||||
});
|
||||
|
||||
interface PasswordResetFormProps {
|
||||
resetToken?: string;
|
||||
}
|
||||
|
||||
export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
|
||||
const { passwordReset, isLoading } = useAuth();
|
||||
useRedirectIfAuthenticated();
|
||||
|
||||
const form = useForm<IPasswordReset>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
newPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: IPasswordReset) {
|
||||
await passwordReset({
|
||||
token: resetToken,
|
||||
newPassword: data.newPassword
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} my={40} className={classes.container}>
|
||||
<Box p="xl" mt={200}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
Password reset
|
||||
</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
placeholder="Your new password"
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("newPassword")}
|
||||
/>
|
||||
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
Set password
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@ -15,8 +15,8 @@ import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import classes from "@/features/auth/components/auth.module.css";
|
||||
|
||||
const formSchema = z.object({
|
||||
workspaceName: z.string().min(2).max(60),
|
||||
name: z.string().min(2).max(60),
|
||||
workspaceName: z.string().trim().min(3).max(50),
|
||||
name: z.string().min(1).max(50),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: "email is required" })
|
||||
|
||||
@ -1,16 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import { login, setupWorkspace } from "@/features/auth/services/auth-service";
|
||||
import {
|
||||
forgotPassword,
|
||||
login,
|
||||
passwordReset,
|
||||
setupWorkspace,
|
||||
verifyUserToken,
|
||||
} from "@/features/auth/services/auth-service";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { ILogin, ISetupWorkspace } from "@/features/auth/types/auth.types";
|
||||
import {
|
||||
IForgotPassword,
|
||||
ILogin,
|
||||
IPasswordReset,
|
||||
ISetupWorkspace,
|
||||
IVerifyUserToken,
|
||||
} from "@/features/auth/types/auth.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
|
||||
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
|
||||
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);
|
||||
@ -18,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);
|
||||
@ -76,6 +90,28 @@ export default function useAuth() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordReset = async (data: IPasswordReset) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await passwordReset(data);
|
||||
setIsLoading(false);
|
||||
|
||||
setAuthToken(res.tokens);
|
||||
|
||||
navigate(APP_ROUTE.HOME);
|
||||
notifications.show({
|
||||
message: "Password reset was successful",
|
||||
});
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
message: err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleIsAuthenticated = async () => {
|
||||
if (!authToken) {
|
||||
return false;
|
||||
@ -102,7 +138,44 @@ 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) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await forgotPassword(data);
|
||||
setIsLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
message: err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyUserToken = async (data: IVerifyUserToken) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await verifyUserToken(data);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
message: err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
@ -110,6 +183,9 @@ export default function useAuth() {
|
||||
invitationSignup: handleInvitationSignUp,
|
||||
setupWorkspace: handleSetupWorkspace,
|
||||
isAuthenticated: handleIsAuthenticated,
|
||||
forgotPassword: handleForgotPassword,
|
||||
passwordReset: handlePasswordReset,
|
||||
verifyUserToken: handleVerifyUserToken,
|
||||
logout: handleLogout,
|
||||
hasTokens,
|
||||
isLoading,
|
||||
|
||||
14
apps/client/src/features/auth/queries/auth-query.tsx
Normal file
14
apps/client/src/features/auth/queries/auth-query.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { verifyUserToken } from "../services/auth-service";
|
||||
import { IVerifyUserToken } from "../types/auth.types";
|
||||
|
||||
export function useVerifyUserTokenQuery(
|
||||
verify: IVerifyUserToken,
|
||||
): UseQueryResult<any, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["verify-token", verify],
|
||||
queryFn: () => verifyUserToken(verify),
|
||||
enabled: !!verify.token,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@ -1,10 +1,13 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IChangePassword,
|
||||
IForgotPassword,
|
||||
ILogin,
|
||||
IPasswordReset,
|
||||
IRegister,
|
||||
ISetupWorkspace,
|
||||
ITokenResponse,
|
||||
IVerifyUserToken,
|
||||
} from "@/features/auth/types/auth.types";
|
||||
|
||||
export async function login(data: ILogin): Promise<ITokenResponse> {
|
||||
@ -19,15 +22,30 @@ export async function register(data: IRegister): Promise<ITokenResponse> {
|
||||
}*/
|
||||
|
||||
export async function changePassword(
|
||||
data: IChangePassword,
|
||||
data: IChangePassword
|
||||
): Promise<IChangePassword> {
|
||||
const req = await api.post<IChangePassword>("/auth/change-password", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setupWorkspace(
|
||||
data: ISetupWorkspace,
|
||||
data: ISetupWorkspace
|
||||
): Promise<ITokenResponse> {
|
||||
const req = await api.post<ITokenResponse>("/auth/setup", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function forgotPassword(data: IForgotPassword): Promise<void> {
|
||||
await api.post<any>("/auth/forgot-password", data);
|
||||
}
|
||||
|
||||
export async function passwordReset(
|
||||
data: IPasswordReset
|
||||
): Promise<ITokenResponse> {
|
||||
const req = await api.post<any>("/auth/password-reset", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||
return api.post<any>("/auth/verify-token", data);
|
||||
}
|
||||
|
||||
@ -29,3 +29,17 @@ export interface IChangePassword {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface IForgotPassword {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface IPasswordReset {
|
||||
token?: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface IVerifyUserToken {
|
||||
token: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import { formatBytes } from "@/lib";
|
||||
|
||||
export default function AttachmentView(props: NodeViewProps) {
|
||||
const { node, selected } = props;
|
||||
const { url, name, size } = node.attrs;
|
||||
const { hovered, ref } = useHover();
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<Paper withBorder p="4px" ref={ref} data-drag-handle>
|
||||
<Group
|
||||
justify="space-between"
|
||||
gap="xl"
|
||||
style={{ cursor: "pointer" }}
|
||||
wrap="nowrap"
|
||||
h={25}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<IconPaperclip size={20} />
|
||||
|
||||
<Text component="span" size="md" truncate="end">
|
||||
{name}
|
||||
</Text>
|
||||
|
||||
<Text component="span" size="sm" c="dimmed" inline>
|
||||
{formatBytes(size)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{selected || hovered ? (
|
||||
<a href={getFileUrl(url)} target="_blank">
|
||||
<ActionIcon variant="default" aria-label="download file">
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { handleAttachmentUpload } from "@docmost/editor-ext";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {getFileUploadSizeLimit} from "@/lib/config.ts";
|
||||
import {formatBytes} from "@/lib";
|
||||
|
||||
export const uploadAttachmentAction = handleAttachmentUpload({
|
||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||
try {
|
||||
return await uploadFile(file, pageId);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: err?.response.data.message,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
validateFn: (file) => {
|
||||
if (file.type.includes("image/") || file.type.includes("video/")) {
|
||||
return false;
|
||||
}
|
||||
if (file.size > getFileUploadSizeLimit()) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
import {
|
||||
Editor,
|
||||
NodeViewContent,
|
||||
NodeViewProps,
|
||||
NodeViewWrapper,
|
||||
} from "@tiptap/react";
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import {
|
||||
IconAlertTriangleFilled,
|
||||
IconCircleCheckFilled,
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||
import { ActionIcon, CopyButton, Group, Select, Tooltip } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
||||
//import MermaidView from "@/features/editor/components/code-block/mermaid-view.tsx";
|
||||
import classes from './code-block.module.css';
|
||||
import React from 'react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const MermaidView = React.lazy(
|
||||
() => import('@/features/editor/components/code-block/mermaid-view.tsx')
|
||||
);
|
||||
|
||||
export default function CodeBlockView(props: NodeViewProps) {
|
||||
const { node, updateAttributes, extension, editor, getPos } = props;
|
||||
const { language } = node.attrs;
|
||||
const [languageValue, setLanguageValue] = useState<string | null>(
|
||||
language || null
|
||||
);
|
||||
const [isSelected, setIsSelected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateSelection = () => {
|
||||
const { state } = editor;
|
||||
const { from, to } = state.selection;
|
||||
// Check if the selection intersects with the node's range
|
||||
const isNodeSelected =
|
||||
(from >= getPos() && from < getPos() + node.nodeSize) ||
|
||||
(to > getPos() && to <= getPos() + node.nodeSize);
|
||||
setIsSelected(isNodeSelected);
|
||||
};
|
||||
|
||||
editor.on('selectionUpdate', updateSelection);
|
||||
return () => {
|
||||
editor.off('selectionUpdate', updateSelection);
|
||||
};
|
||||
}, [editor, getPos(), node.nodeSize]);
|
||||
|
||||
function changeLanguage(language: string) {
|
||||
setLanguageValue(language);
|
||||
updateAttributes({
|
||||
language: language,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="codeBlock">
|
||||
<Group justify="flex-end" contentEditable={false}>
|
||||
<Select
|
||||
placeholder="auto"
|
||||
checkIconPosition="right"
|
||||
data={extension.options.lowlight.listLanguages().sort()}
|
||||
value={languageValue}
|
||||
onChange={changeLanguage}
|
||||
searchable
|
||||
style={{ maxWidth: '130px' }}
|
||||
classNames={{ input: classes.selectInput }}
|
||||
disabled={!editor.isEditable}
|
||||
/>
|
||||
|
||||
<CopyButton value={node?.textContent} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? 'Copied' : 'Copy'}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<ActionIcon
|
||||
color={copied ? 'teal' : 'gray'}
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
|
||||
<pre
|
||||
spellCheck="false"
|
||||
hidden={
|
||||
((language === 'mermaid' && !editor.isEditable) ||
|
||||
(language === 'mermaid' && !isSelected)) &&
|
||||
node.textContent.length > 0
|
||||
}
|
||||
>
|
||||
<NodeViewContent as="code" className={`language-${language}`} />
|
||||
</pre>
|
||||
|
||||
{language === 'mermaid' && (
|
||||
<Suspense fallback={null}>
|
||||
<MermaidView props={props} />
|
||||
</Suspense>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
.selectInput {
|
||||
height: 25px;
|
||||
min-height: 25px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mermaid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
import { NodeViewProps } from "@tiptap/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import mermaid from "mermaid";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import classes from "./code-block.module.css";
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
interface MermaidViewProps {
|
||||
props: NodeViewProps;
|
||||
}
|
||||
|
||||
export default function MermaidView({ props }: MermaidViewProps) {
|
||||
const { node } = props;
|
||||
const [preview, setPreview] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const id = `mermaid-${uuidv4()}`;
|
||||
|
||||
if (node.textContent.length > 0) {
|
||||
mermaid
|
||||
.render(id, node.textContent)
|
||||
.then((item) => {
|
||||
setPreview(item.svg);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (props.editor.isEditable) {
|
||||
setPreview(
|
||||
`<div class="${classes.error}">Mermaid diagram error: ${err}</div>`,
|
||||
);
|
||||
} else {
|
||||
setPreview(
|
||||
`<div class="${classes.error}">Invalid Mermaid Diagram</div>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [node.textContent]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.mermaid}
|
||||
contentEditable={false}
|
||||
dangerouslySetInnerHTML={{ __html: preview }}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||
|
||||
export const handleFilePaste = (
|
||||
view: EditorView,
|
||||
@ -15,6 +16,7 @@ export const handleFilePaste = (
|
||||
if (file) {
|
||||
uploadImageAction(file, view, pos, pageId);
|
||||
uploadVideoAction(file, view, pos, pageId);
|
||||
uploadAttachmentAction(file, view, pos, pageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@ -38,6 +40,7 @@ export const handleFileDrop = (
|
||||
if (file) {
|
||||
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1,28 +1,34 @@
|
||||
import React, { memo, useCallback, useState } from "react";
|
||||
import { Slider } from "@mantine/core";
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { Slider } from '@mantine/core';
|
||||
|
||||
export type ImageWidthProps = {
|
||||
onChange: (value: number) => void;
|
||||
value: number;
|
||||
width?: string;
|
||||
};
|
||||
|
||||
export const NodeWidthResize = memo(({ onChange, value }: ImageWidthProps) => {
|
||||
export const NodeWidthResize = memo(({ onChange, value, width }: ImageWidthProps) => {
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: number) => {
|
||||
onChange(newValue);
|
||||
},
|
||||
[onChange],
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Slider
|
||||
p={"sm"}
|
||||
p={'sm'}
|
||||
min={10}
|
||||
value={currentValue}
|
||||
onChange={setCurrentValue}
|
||||
onChangeEnd={handleChange}
|
||||
w={width || 100}
|
||||
label={(value) => `${value}%`}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
} from '@tiptap/react';
|
||||
import { useCallback } from 'react';
|
||||
import { sticky } from 'tippy.js';
|
||||
import { Node as PMNode } from 'prosemirror-model';
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from '@/features/editor/components/table/types/types.ts';
|
||||
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
|
||||
|
||||
export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive('drawio') && editor.getAttributes('drawio')?.src;
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === 'drawio';
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor.commands.updateAttributes('drawio', { width: `${value}%` });
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`drawio-menu}`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: 'flip', enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: 'popper',
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{editor.getAttributes('drawio')?.width && (
|
||||
<NodeWidthResize
|
||||
onChange={onWidthChange}
|
||||
value={parseInt(editor.getAttributes('drawio').width)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default DrawioMenu;
|
||||
@ -0,0 +1,175 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||
import { ActionIcon, Card, Image, Modal, Text, useComputedColorScheme } from '@mantine/core';
|
||||
import { useRef, useState } from 'react';
|
||||
import { uploadFile } from '@/features/page/services/page-service.ts';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { getDrawioUrl, getFileUrl } from '@/lib/config.ts';
|
||||
import {
|
||||
DrawIoEmbed,
|
||||
DrawIoEmbedRef,
|
||||
EventExit,
|
||||
EventSave,
|
||||
} from 'react-drawio';
|
||||
import { IAttachment } from '@/lib/types';
|
||||
import { decodeBase64ToSvgString, svgStringToFile } from '@/lib/utils';
|
||||
import clsx from 'clsx';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
|
||||
export default function DrawioView(props: NodeViewProps) {
|
||||
const { node, updateAttributes, editor, selected } = props;
|
||||
const { src, title, width, attachmentId } = node.attrs;
|
||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||
const [initialXML, setInitialXML] = useState<string>('');
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (src) {
|
||||
const url = getFileUrl(src);
|
||||
const request = await fetch(url, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
const blob = await request.blob();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
reader.onloadend = () => {
|
||||
const base64data = (reader.result || '') as string;
|
||||
setInitialXML(base64data);
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (data: EventSave) => {
|
||||
const svgString = decodeBase64ToSvgString(data.xml);
|
||||
|
||||
const fileName = 'diagram.drawio.svg';
|
||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
const pageId = editor.storage?.pageId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
|
||||
if (attachmentId) {
|
||||
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||
} else {
|
||||
attachment = await uploadFile(drawioSVGFile, pageId);
|
||||
}
|
||||
|
||||
updateAttributes({
|
||||
src: `/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<Modal.Root opened={opened} onClose={close} fullScreen>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: 'hidden' }}>
|
||||
<Modal.Body>
|
||||
<div style={{ height: '100vh' }}>
|
||||
<DrawIoEmbed
|
||||
ref={drawioRef}
|
||||
xml={initialXML}
|
||||
baseUrl={getDrawioUrl()}
|
||||
urlParameters={{
|
||||
ui: computedColorScheme === 'light' ? 'kennedy' : 'dark',
|
||||
spin: true,
|
||||
libraries: true,
|
||||
saveAndExit: true,
|
||||
noSaveBtn: true,
|
||||
}}
|
||||
onSave={(data: EventSave) => {
|
||||
// If the save is triggered by another event, then do nothing
|
||||
if (data.parentEvent !== 'save') {
|
||||
return;
|
||||
}
|
||||
handleSave(data);
|
||||
}}
|
||||
onClose={(data: EventExit) => {
|
||||
// If the exit is triggered by another event, then do nothing
|
||||
if (data.parentEvent) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
{src ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Image
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(
|
||||
selected ? 'ProseMirror-selectednode' : '',
|
||||
'alignCenter'
|
||||
)}
|
||||
/>
|
||||
|
||||
{selected && (
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
color="gray"
|
||||
mx="xs"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
radius="md"
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
p="xs"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
withBorder
|
||||
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
Double-click to edit drawio diagram
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
111
apps/client/src/features/editor/components/embed/embed-view.tsx
Normal file
111
apps/client/src/features/editor/components/embed/embed-view.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useMemo } from "react";
|
||||
import clsx from "clsx";
|
||||
import { ActionIcon, AspectRatio, Button, Card, FocusTrap, Group, Popover, Text, TextInput } from "@mantine/core";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { z } from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import {
|
||||
getEmbedProviderById,
|
||||
getEmbedUrlAndProvider
|
||||
} from "@/features/editor/components/embed/providers.ts";
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
const schema = z.object({
|
||||
url: z
|
||||
.string().trim().url({ message: 'please enter a valid url' }),
|
||||
});
|
||||
|
||||
export default function EmbedView(props: NodeViewProps) {
|
||||
const { node, selected, updateAttributes } = props;
|
||||
const { src, provider } = node.attrs;
|
||||
|
||||
const embedUrl = useMemo(() => {
|
||||
if (src) {
|
||||
return getEmbedUrlAndProvider(src).embedUrl;
|
||||
}
|
||||
return null;
|
||||
}, [src]);
|
||||
|
||||
const embedForm = useForm<{ url: string }>({
|
||||
initialValues: {
|
||||
url: "",
|
||||
},
|
||||
validate: zodResolver(schema),
|
||||
});
|
||||
|
||||
async function onSubmit(data: { url: string }) {
|
||||
if (provider) {
|
||||
const embedProvider = getEmbedProviderById(provider);
|
||||
if (embedProvider.regex.test(data.url)) {
|
||||
updateAttributes({ src: data.url });
|
||||
} else {
|
||||
notifications.show({
|
||||
message: `Invalid ${provider} embed link`,
|
||||
position: 'top-right',
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
{embedUrl ? (
|
||||
<>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
></iframe>
|
||||
</AspectRatio>
|
||||
|
||||
</>
|
||||
) : (
|
||||
<Popover width={300} position="bottom" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Card
|
||||
radius="md"
|
||||
p="xs"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
withBorder
|
||||
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
Embed {getEmbedProviderById(provider).name}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown bg="var(--mantine-color-body)">
|
||||
<form onSubmit={embedForm.onSubmit(onSubmit)}>
|
||||
<FocusTrap active={true}>
|
||||
<TextInput placeholder={`Enter ${getEmbedProviderById(provider).name} link to embed`}
|
||||
key={embedForm.key('url')}
|
||||
{... embedForm.getInputProps('url')}
|
||||
data-autofocus
|
||||
/>
|
||||
</FocusTrap>
|
||||
|
||||
<Group justify="center" mt="xs">
|
||||
<Button type="submit">Embed link</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
121
apps/client/src/features/editor/components/embed/providers.ts
Normal file
121
apps/client/src/features/editor/components/embed/providers.ts
Normal file
@ -0,0 +1,121 @@
|
||||
export interface IEmbedProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
getEmbedUrl: (match: RegExpMatchArray, url?: string) => string;
|
||||
}
|
||||
|
||||
export const embedProviders: IEmbedProvider[] = [
|
||||
{
|
||||
id: 'loom',
|
||||
name: 'Loom',
|
||||
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
|
||||
getEmbedUrl: (match, url) => {
|
||||
if(url.includes("/embed/")){
|
||||
return url;
|
||||
}
|
||||
return `https://loom.com/embed/${match[1]}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'airtable',
|
||||
name: 'Airtable',
|
||||
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]}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'figma',
|
||||
name: 'Figma',
|
||||
regex: /^https:\/\/[\w\.-]+\.?figma.com\/(file|proto|board|design|slides|deck)\/([0-9a-zA-Z]{22,128})/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return `https://www.figma.com/embed?url=${url}&embed_host=docmost`;
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'typeform',
|
||||
name: 'Typeform',
|
||||
regex: /^(https?:)?(\/\/)?[\w\.]+\.typeform\.com\/to\/.+/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return url;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'miro',
|
||||
name: 'Miro',
|
||||
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
|
||||
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`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'youtube',
|
||||
name: 'YouTube',
|
||||
regex: /^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
||||
getEmbedUrl: (match, url) => {
|
||||
if (url.includes("/embed/")){
|
||||
return url;
|
||||
}
|
||||
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'vimeo',
|
||||
name: 'Vimeo',
|
||||
regex: /^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)/,
|
||||
getEmbedUrl: (match) => {
|
||||
return `https://player.vimeo.com/video/${match[4]}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'framer',
|
||||
name: 'Framer',
|
||||
regex: /^https:\/\/(www\.)?framer\.com\/embed\/([\w-]+)/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return url;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'gdrive',
|
||||
name: 'Google Drive',
|
||||
regex: /^((?:https?:)?\/\/)?((?:www|m)\.)?(drive\.google\.com)\/file\/d\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||
getEmbedUrl: (match) => {
|
||||
return `https://drive.google.com/file/d/${match[4]}/preview`;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
export function getEmbedProviderById(id: string) {
|
||||
return embedProviders.find(provider => provider.id.toLowerCase() === id.toLowerCase());
|
||||
}
|
||||
|
||||
export interface IEmbedResult {
|
||||
embedUrl: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export function getEmbedUrlAndProvider(url: string): IEmbedResult {
|
||||
for (const provider of embedProviders) {
|
||||
const match = url.match(provider.regex);
|
||||
if (match) {
|
||||
return {
|
||||
embedUrl: provider.getEmbedUrl(match, url),
|
||||
provider: provider.name.toLowerCase()
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
embedUrl: url,
|
||||
provider: 'iframe',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
} from '@tiptap/react';
|
||||
import { useCallback } from 'react';
|
||||
import { sticky } from 'tippy.js';
|
||||
import { Node as PMNode } from 'prosemirror-model';
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from '@/features/editor/components/table/types/types.ts';
|
||||
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
|
||||
|
||||
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src;
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === 'excalidraw';
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor.commands.updateAttributes('excalidraw', { width: `${value}%` });
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`excalidraw-menu}`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: 'flip', enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: 'popper',
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{editor.getAttributes('excalidraw')?.width && (
|
||||
<NodeWidthResize
|
||||
onChange={onWidthChange}
|
||||
value={parseInt(editor.getAttributes('excalidraw').width)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExcalidrawMenu;
|
||||
@ -0,0 +1,213 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { uploadFile } from '@/features/page/services/page-service.ts';
|
||||
import { svgStringToFile } from '@/lib';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { getFileUrl } from '@/lib/config.ts';
|
||||
import { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types';
|
||||
import { IAttachment } from '@/lib/types';
|
||||
import ReactClearModal from 'react-clear-modal';
|
||||
import clsx from 'clsx';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
import { lazy } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const Excalidraw = lazy(() =>
|
||||
import('@excalidraw/excalidraw').then((module) => ({
|
||||
default: module.Excalidraw,
|
||||
}))
|
||||
);
|
||||
|
||||
export default function ExcalidrawView(props: NodeViewProps) {
|
||||
const { node, updateAttributes, editor, selected } = props;
|
||||
const { src, title, width, attachmentId } = node.attrs;
|
||||
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI>(null);
|
||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (src) {
|
||||
const url = getFileUrl(src);
|
||||
const request = await fetch(url, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const { loadFromBlob } = await import('@excalidraw/excalidraw');
|
||||
|
||||
const data = await loadFromBlob(await request.blob(), null, null);
|
||||
setExcalidrawData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { exportToSvg } = await import('@excalidraw/excalidraw');
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: excalidrawAPI?.getSceneElements(),
|
||||
appState: {
|
||||
exportEmbedScene: true,
|
||||
exportWithDarkMode: false,
|
||||
},
|
||||
files: excalidrawAPI?.getFiles(),
|
||||
});
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
let svgString = serializer.serializeToString(svg);
|
||||
|
||||
svgString = svgString.replace(
|
||||
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
||||
'https://unpkg.com/@excalidraw/excalidraw@latest'
|
||||
);
|
||||
|
||||
const fileName = 'diagram.excalidraw.svg';
|
||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
const pageId = editor.storage?.pageId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
if (attachmentId) {
|
||||
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
|
||||
} else {
|
||||
attachment = await uploadFile(excalidrawSvgFile, pageId);
|
||||
}
|
||||
|
||||
updateAttributes({
|
||||
src: `/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<ReactClearModal
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
padding: 0,
|
||||
zIndex: 200,
|
||||
}}
|
||||
isOpen={opened}
|
||||
onRequestClose={close}
|
||||
disableCloseOnBgClick={true}
|
||||
contentProps={{
|
||||
style: {
|
||||
padding: 0,
|
||||
width: '90vw',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
bg="var(--mantine-color-body)"
|
||||
p="xs"
|
||||
>
|
||||
<Button onClick={handleSave} size={'compact-sm'}>
|
||||
Save & Exit
|
||||
</Button>
|
||||
<Button onClick={close} color="red" size={'compact-sm'}>
|
||||
Exit
|
||||
</Button>
|
||||
</Group>
|
||||
<div style={{ height: '90vh' }}>
|
||||
<Suspense fallback={null}>
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
initialData={{
|
||||
...excalidrawData,
|
||||
scrollToContent: true,
|
||||
}}
|
||||
theme={computedColorScheme}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ReactClearModal>
|
||||
|
||||
{src ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Image
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(
|
||||
selected ? 'ProseMirror-selectednode' : '',
|
||||
'alignCenter'
|
||||
)}
|
||||
/>
|
||||
|
||||
{selected && (
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
color="gray"
|
||||
mx="xs"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
radius="md"
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
p="xs"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
withBorder
|
||||
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
Double-click to edit Excalidraw diagram
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,18 @@
|
||||
import { handleImageUpload } from "@docmost/editor-ext";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {getFileUploadSizeLimit} from "@/lib/config.ts";
|
||||
import { formatBytes } from "@/lib";
|
||||
|
||||
export const uploadImageAction = handleImageUpload({
|
||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||
try {
|
||||
return await uploadFile(file, pageId);
|
||||
} catch (err) {
|
||||
console.error("failed to upload image", err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: err?.response.data.message,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@ -14,8 +20,11 @@ export const uploadImageAction = handleImageUpload({
|
||||
if (!file.type.includes("image/")) {
|
||||
return false;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 20) {
|
||||
//error("File size too big (max 20MB).");
|
||||
if (file.size > getFileUploadSizeLimit()) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@ -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));
|
||||
@ -17,10 +18,6 @@
|
||||
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
|
||||
}
|
||||
|
||||
&:not(.error, .empty) * {
|
||||
font-family: KaTeX_Main, Times New Roman, serif;
|
||||
}
|
||||
}
|
||||
|
||||
.mathBlock {
|
||||
@ -33,7 +30,8 @@
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
margin: 0 0.1rem;
|
||||
overflow-x: scroll;
|
||||
overflow-x: auto;
|
||||
user-select: none;
|
||||
|
||||
.textInput {
|
||||
width: 400px;
|
||||
@ -52,10 +50,4 @@
|
||||
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
|
||||
}
|
||||
|
||||
&:not(.error, .empty) * {
|
||||
font-family: KaTeX_Main, Times New Roman, serif;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -12,9 +12,12 @@ import {
|
||||
IconMath,
|
||||
IconMathFunction,
|
||||
IconMovie,
|
||||
IconPaperclip,
|
||||
IconPhoto,
|
||||
IconTable,
|
||||
IconTypography,
|
||||
IconMenu4,
|
||||
IconCalendar,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
@ -22,6 +25,20 @@ import {
|
||||
} from "@/features/editor/components/slash-menu/types";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
|
||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||
import IconDrawio from "@/components/icons/icon-drawio";
|
||||
import {
|
||||
AirtableIcon,
|
||||
FigmaIcon,
|
||||
FramerIcon,
|
||||
GoogleDriveIcon,
|
||||
LoomIcon,
|
||||
MiroIcon,
|
||||
TypeformIcon,
|
||||
VimeoIcon, YoutubeIcon
|
||||
} from "@/components/icons";
|
||||
|
||||
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
basic: [
|
||||
@ -118,15 +135,23 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
},
|
||||
{
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
description: "Insert code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: IconCode,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
title: "Divider",
|
||||
description: "Insert horizontal rule divider",
|
||||
searchTerms: ["horizontal rule", "hr"],
|
||||
icon: IconMenu4,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
|
||||
},
|
||||
{
|
||||
title: "Image",
|
||||
description: "Upload an image from your computer.",
|
||||
description: "Upload any image from your device.",
|
||||
searchTerms: ["photo", "picture", "media"],
|
||||
icon: IconPhoto,
|
||||
command: ({ editor, range }) => {
|
||||
@ -139,11 +164,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadImageAction(file, editor.view, pos, pageId);
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadImageAction(file, editor.view, pos, pageId);
|
||||
}
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
@ -151,7 +178,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
},
|
||||
{
|
||||
title: "Video",
|
||||
description: "Upload an video from your computer.",
|
||||
description: "Upload any video from your device.",
|
||||
searchTerms: ["video", "mp4", "media"],
|
||||
icon: IconMovie,
|
||||
command: ({ editor, range }) => {
|
||||
@ -174,6 +201,37 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "File attachment",
|
||||
description: "Upload any file from your device.",
|
||||
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
|
||||
icon: IconPaperclip,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
// upload file
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "";
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
if (file.type.includes("image/*")) {
|
||||
uploadImageAction(file, editor.view, pos, pageId);
|
||||
} else if (file.type.includes("video/*")) {
|
||||
uploadVideoAction(file, editor.view, pos, pageId);
|
||||
} else {
|
||||
uploadAttachmentAction(file, editor.view, pos, pageId);
|
||||
}
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Table",
|
||||
description: "Insert a table.",
|
||||
@ -184,7 +242,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: false })
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
@ -253,6 +311,137 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setMathBlock().run(),
|
||||
},
|
||||
{
|
||||
title: "Mermaid diagram",
|
||||
description: "Insert mermaid diagram",
|
||||
searchTerms: ["mermaid", "diagrams", "chart", "uml"],
|
||||
icon: IconMermaid,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setCodeBlock({ language: "mermaid" })
|
||||
.insertContent("flowchart LR\n" + " A --> B")
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "Draw.io (diagrams.net) ",
|
||||
description: "Insert and design Drawio diagrams",
|
||||
searchTerms: ["drawio", "diagrams", "charts", "uml", "whiteboard"],
|
||||
icon: IconDrawio,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setDrawio().run(),
|
||||
},
|
||||
{
|
||||
title: "Excalidraw diagram",
|
||||
description: "Draw and sketch excalidraw diagrams",
|
||||
searchTerms: ["diagrams", "draw", "sketch", "whiteboard"],
|
||||
icon: IconExcalidraw,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setExcalidraw().run(),
|
||||
},
|
||||
{
|
||||
title: "Date",
|
||||
description: "Insert current date",
|
||||
searchTerms: ["date", "today"],
|
||||
icon: IconCalendar,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
const currentDate = new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent(currentDate)
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Airtable",
|
||||
description: "Embed Airtable",
|
||||
searchTerms: ["airtable"],
|
||||
icon: AirtableIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'airtable' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Loom",
|
||||
description: "Embed Loom video",
|
||||
searchTerms: ["loom"],
|
||||
icon: LoomIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'loom' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Figma",
|
||||
description: "Embed Figma files",
|
||||
searchTerms: ["figma"],
|
||||
icon: FigmaIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'figma' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Typeform",
|
||||
description: "Embed Typeform",
|
||||
searchTerms: ["typeform"],
|
||||
icon: TypeformIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'typeform' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Miro",
|
||||
description: "Embed Miro board",
|
||||
searchTerms: ["miro"],
|
||||
icon: MiroIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'miro' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "YouTube",
|
||||
description: "Embed YouTube video",
|
||||
searchTerms: ["youtube", "yt"],
|
||||
icon: YoutubeIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'youtube' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Vimeo",
|
||||
description: "Embed Vimeo video",
|
||||
searchTerms: ["vimeo"],
|
||||
icon: VimeoIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'vimeo' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Framer",
|
||||
description: "Embed Framer prototype",
|
||||
searchTerms: ["framer"],
|
||||
icon: FramerIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'framer' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Google Drive",
|
||||
description: "Embed Google Drive content",
|
||||
searchTerms: ["google drive", "gdrive"],
|
||||
icon: GoogleDriveIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'gdrive' }).run();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -264,10 +453,10 @@ export const getSuggestionItems = ({
|
||||
const search = query.toLowerCase();
|
||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||
|
||||
const fuzzyMatch = (query, target) => {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import { handleVideoUpload } from "@docmost/editor-ext";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {getFileUploadSizeLimit} from "@/lib/config.ts";
|
||||
import {formatBytes} from "@/lib";
|
||||
|
||||
export const uploadVideoAction = handleVideoUpload({
|
||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||
try {
|
||||
return await uploadFile(file, pageId);
|
||||
} catch (err) {
|
||||
console.error("failed to upload image", err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: err?.response.data.message,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@ -15,9 +21,14 @@ export const uploadVideoAction = handleVideoUpload({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size / 1024 / 1024 > 20) {
|
||||
if (file.size > getFileUploadSizeLimit()) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,371 +0,0 @@
|
||||
// MIT - source: https://github.com/NiclasDev63/tiptap-extension-global-drag-handle
|
||||
import { Extension } from "@tiptap/core";
|
||||
import {
|
||||
NodeSelection,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
TextSelection,
|
||||
} from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
||||
|
||||
// @ts-ignore
|
||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
||||
|
||||
export interface GlobalDragHandleOptions {
|
||||
/**
|
||||
* The width of the drag handle
|
||||
*/
|
||||
dragHandleWidth: number;
|
||||
|
||||
/**
|
||||
* The treshold for scrolling
|
||||
*/
|
||||
scrollTreshold: number;
|
||||
|
||||
/*
|
||||
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
||||
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
||||
*/
|
||||
dragHandleSelector?: string;
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
const modal = node.closest('[role="dialog"]');
|
||||
|
||||
if (modal && window.getComputedStyle(modal).transform !== "none") {
|
||||
const modalRect = modal.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top - modalRect.top,
|
||||
left: data.left - modalRect.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: data.top,
|
||||
left: data.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
||||
return document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find(
|
||||
(elem: Element) =>
|
||||
elem.parentElement?.matches?.(".ProseMirror") ||
|
||||
elem.matches(
|
||||
[
|
||||
"li",
|
||||
"p:not(:first-child)",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"h1, h2, h3, h4, h5, h6",
|
||||
".tableWrapper",
|
||||
].join(", "),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function nodePosAtDOM(
|
||||
node: Element,
|
||||
view: EditorView,
|
||||
options: GlobalDragHandleOptions,
|
||||
) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||
top: boundingRect.top + 1,
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function calcNodePos(pos: number, view: EditorView) {
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
||||
return pos;
|
||||
}
|
||||
|
||||
export function DragHandlePlugin(
|
||||
options: GlobalDragHandleOptions & { pluginKey: string },
|
||||
) {
|
||||
let listType = "";
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
||||
|
||||
const { from, to } = view.state.selection;
|
||||
const diff = from - to;
|
||||
|
||||
const fromSelectionPos = calcNodePos(from, view);
|
||||
let differentNodeSelected = false;
|
||||
|
||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||
|
||||
// Check if nodePos points to the top level node
|
||||
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
||||
else {
|
||||
const nodeSelection = NodeSelection.create(
|
||||
view.state.doc,
|
||||
nodePos.before(),
|
||||
);
|
||||
// Check if the node where the drag event started is part of the current selection
|
||||
differentNodeSelected = !(
|
||||
draggedNodePos + 1 >= nodeSelection.$from.pos &&
|
||||
draggedNodePos <= nodeSelection.$to.pos
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!differentNodeSelected &&
|
||||
diff !== 0 &&
|
||||
!(view.state.selection instanceof NodeSelection)
|
||||
) {
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||
const multiNodeSelection = TextSelection.create(
|
||||
view.state.doc,
|
||||
draggedNodePos,
|
||||
endSelection.$to.pos,
|
||||
);
|
||||
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
|
||||
} else {
|
||||
const nodeSelection = NodeSelection.create(
|
||||
view.state.doc,
|
||||
draggedNodePos,
|
||||
);
|
||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
||||
}
|
||||
|
||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem"
|
||||
) {
|
||||
listType = node.parentElement!.tagName;
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = __serializeForClipboard(view, slice);
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData("text/html", dom.innerHTML);
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
event.dataTransfer.effectAllowed = "copyMove";
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
|
||||
function hideDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.add("hide");
|
||||
}
|
||||
}
|
||||
|
||||
function showDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.remove("hide");
|
||||
}
|
||||
}
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey(options.pluginKey),
|
||||
view: (view) => {
|
||||
const handleBySelector = options.dragHandleSelector
|
||||
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
||||
: null;
|
||||
dragHandleElement = handleBySelector ?? document.createElement("div");
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = "";
|
||||
dragHandleElement.classList.add("drag-handle");
|
||||
|
||||
function onDragHandleDragStart(e: DragEvent) {
|
||||
handleDragStart(e, view);
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
|
||||
|
||||
function onDragHandleDrag(e: DragEvent) {
|
||||
hideDragHandle();
|
||||
let scrollY = window.scrollY;
|
||||
if (e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener("drag", onDragHandleDrag);
|
||||
|
||||
hideDragHandle();
|
||||
|
||||
if (!handleBySelector) {
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
}
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
if (!handleBySelector) {
|
||||
dragHandleElement?.remove?.();
|
||||
}
|
||||
dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
|
||||
dragHandleElement?.removeEventListener(
|
||||
"dragstart",
|
||||
onDragHandleDragStart,
|
||||
);
|
||||
dragHandleElement = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const notDragging = node?.closest(".not-draggable");
|
||||
|
||||
//const isNodeInTable = node?.closest("td") || node?.closest("th");
|
||||
|
||||
if (
|
||||
!(node instanceof Element) ||
|
||||
node.matches("ul, ol") ||
|
||||
notDragging ||
|
||||
node.matches(".tableWrapper")
|
||||
) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
}
|
||||
|
||||
const compStyle = window.getComputedStyle(node);
|
||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
||||
const lineHeight = isNaN(parsedLineHeight)
|
||||
? parseInt(compStyle.fontSize) * 1.2
|
||||
: parsedLineHeight;
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
|
||||
rect.top += (lineHeight - 24) / 2;
|
||||
rect.top += paddingTop;
|
||||
// Li markers
|
||||
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
||||
rect.left -= options.dragHandleWidth;
|
||||
}
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
// dragging class is used for CSS
|
||||
dragstart: (view) => {
|
||||
view.dom.classList.add("dragging");
|
||||
},
|
||||
drop: (view, event) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!dropPos) return;
|
||||
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
if (!droppedNode) return;
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
|
||||
const isDroppedInsideList =
|
||||
resolvedPos.parent.type.name === "listItem";
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem" &&
|
||||
!isDroppedInsideList &&
|
||||
listType == "OL"
|
||||
) {
|
||||
const text = droppedNode.textContent;
|
||||
if (!text) return;
|
||||
const paragraph = view.state.schema.nodes.paragraph?.createAndFill(
|
||||
{},
|
||||
view.state.schema.text(text),
|
||||
);
|
||||
const listItem = view.state.schema.nodes.listItem?.createAndFill(
|
||||
{},
|
||||
paragraph,
|
||||
);
|
||||
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(
|
||||
null,
|
||||
listItem,
|
||||
);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const GlobalDragHandle = Extension.create({
|
||||
name: "globalDragHandle",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
dragHandleWidth: 20,
|
||||
scrollTreshold: 100,
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandlePlugin({
|
||||
pluginKey: "globalDragHandle",
|
||||
dragHandleWidth: this.options.dragHandleWidth,
|
||||
scrollTreshold: this.options.scrollTreshold,
|
||||
dragHandleSelector: this.options.dragHandleSelector,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default GlobalDragHandle;
|
||||
@ -4,14 +4,14 @@ 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 { Link } from "@tiptap/extension-link";
|
||||
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 CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import Table from "@tiptap/extension-table";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||
@ -23,8 +23,6 @@ import {
|
||||
DetailsSummary,
|
||||
MathBlock,
|
||||
MathInline,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TrailingNode,
|
||||
@ -33,6 +31,11 @@ import {
|
||||
TiptapVideo,
|
||||
LinkExtension,
|
||||
Selection,
|
||||
Attachment,
|
||||
CustomCodeBlock,
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@ -41,13 +44,38 @@ import {
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
||||
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
||||
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||
import { Youtube } from "@tiptap/extension-youtube";
|
||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
import elixir from "highlight.js/lib/languages/elixir";
|
||||
import erlang from "highlight.js/lib/languages/erlang";
|
||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||
import clojure from "highlight.js/lib/languages/clojure";
|
||||
import fortran from "highlight.js/lib/languages/fortran";
|
||||
import haskell from "highlight.js/lib/languages/haskell";
|
||||
import scala from "highlight.js/lib/languages/scala";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
lowlight.register("powershell", powershell);
|
||||
lowlight.register("powershell", powershell);
|
||||
lowlight.register("erlang", erlang);
|
||||
lowlight.register("elixir", elixir);
|
||||
lowlight.register("dockerfile", dockerfile);
|
||||
lowlight.register("clojure", clojure);
|
||||
lowlight.register("fortran", fortran);
|
||||
lowlight.register("haskell", haskell);
|
||||
lowlight.register("scala", scala);
|
||||
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
@ -57,6 +85,11 @@ export const mainExtensions = [
|
||||
color: "#70CFF8",
|
||||
},
|
||||
codeBlock: false,
|
||||
code: {
|
||||
HTMLAttributes: {
|
||||
spellcheck: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
@ -66,9 +99,12 @@ export const mainExtensions = [
|
||||
if (node.type.name === "detailsSummary") {
|
||||
return "Toggle title";
|
||||
}
|
||||
return 'Write anything. Enter "/" for commands';
|
||||
if (node.type.name === "paragraph") {
|
||||
return 'Write anything. Enter "/" for commands';
|
||||
}
|
||||
},
|
||||
includeChildren: true,
|
||||
showOnlyWhenEditable: true,
|
||||
}),
|
||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||
TaskList,
|
||||
@ -95,10 +131,16 @@ export const mainExtensions = [
|
||||
class: "comment-mark",
|
||||
},
|
||||
}),
|
||||
Table,
|
||||
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
lastColumnResizable: false,
|
||||
allowTableNodeSelection: true,
|
||||
}),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
|
||||
MathInline.configure({
|
||||
view: MathInlineView,
|
||||
}),
|
||||
@ -109,6 +151,7 @@ export const mainExtensions = [
|
||||
DetailsSummary,
|
||||
DetailsContent,
|
||||
Youtube.configure({
|
||||
addPasteHandler: false,
|
||||
controls: true,
|
||||
nocookie: true,
|
||||
}),
|
||||
@ -122,10 +165,26 @@ export const mainExtensions = [
|
||||
Callout.configure({
|
||||
view: CalloutView,
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
CustomCodeBlock.configure({
|
||||
view: CodeBlockView,
|
||||
lowlight,
|
||||
HTMLAttributes: {
|
||||
spellcheck: false,
|
||||
},
|
||||
}),
|
||||
Selection,
|
||||
Attachment.configure({
|
||||
view: AttachmentView,
|
||||
}),
|
||||
Drawio.configure({
|
||||
view: DrawioView,
|
||||
}),
|
||||
Excalidraw.configure({
|
||||
view: ExcalidrawView,
|
||||
}),
|
||||
Embed.configure({
|
||||
view: EmbedView,
|
||||
})
|
||||
] as any;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
|
||||
@ -3,7 +3,7 @@ import React from "react";
|
||||
import { TitleEditor } from "@/features/editor/title-editor";
|
||||
import PageEditor from "@/features/editor/page-editor";
|
||||
import { Container } from "@mantine/core";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
@ -30,8 +30,7 @@ export function FullEditor({
|
||||
return (
|
||||
<Container
|
||||
fluid={fullPageWidth}
|
||||
{...(fullPageWidth && { mx: 80 })}
|
||||
size={850}
|
||||
size={!fullPageWidth && 850}
|
||||
className={classes.editor}
|
||||
>
|
||||
<MemoizedTitleEditor
|
||||
|
||||
@ -31,14 +31,14 @@ import TableCellMenu from "@/features/editor/components/table/table-cell-menu.ts
|
||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||
import {
|
||||
handleFileDrop,
|
||||
handleFilePaste,
|
||||
} from "@/features/editor/components/common/file-upload-handler.tsx";
|
||||
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@ -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(
|
||||
@ -173,6 +173,8 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<DrawioMenu editor={editor} />
|
||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||
</div>
|
||||
)}
|
||||
@ -182,6 +184,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div onClick={() => editor.commands.focus('end')} style={{ paddingBottom: '20vh' }}></div>
|
||||
</div>
|
||||
) : (
|
||||
<EditorSkeleton />
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
.ProseMirror {
|
||||
.codeBlock {
|
||||
padding: 4px;
|
||||
border-radius: var(--mantine-radius-default);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
|
||||
margin: 4px;
|
||||
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
|
||||
border-radius: var(--mantine-radius-default);
|
||||
|
||||
@ -18,7 +25,7 @@
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: inherit;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
/* Code styling */
|
||||
@ -86,4 +93,22 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
|
||||
line-height: var(--mantine-line-height);
|
||||
padding: 2px calc(var(--mantine-spacing-xs) / 2);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
margin: 0;
|
||||
|
||||
@mixin where-light {
|
||||
background-color: var(--code-bg, var(--mantine-color-gray-1));
|
||||
color: var(--mantine-color-pink-7);
|
||||
}
|
||||
|
||||
@mixin where-dark {
|
||||
background-color: var(--mantine-color-dark-8);
|
||||
color: var(--mantine-color-pink-7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,11 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0.65em;
|
||||
margin-bottom: 0.65em;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
@ -27,6 +32,12 @@
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
ul p,
|
||||
ol p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@ -80,15 +91,9 @@
|
||||
outline: 2px solid #70cff8;
|
||||
}
|
||||
|
||||
.node-mathInline {
|
||||
.katex-display {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& > .react-renderer {
|
||||
margin-top: var(--mantine-spacing-xl);
|
||||
margin-bottom: var(--mantine-spacing-xl);
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
@ -163,3 +168,4 @@
|
||||
.actionIconGroup {
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
|
||||
@ -50,8 +50,7 @@
|
||||
|
||||
[data-type="detailsContainer"] {
|
||||
flex: 1;
|
||||
margin-left: 0.2em;
|
||||
overflow-x: hidden;
|
||||
padding: 4px;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
|
||||
@ -2,5 +2,10 @@
|
||||
height: 100%;
|
||||
padding: 8px 20px;
|
||||
margin: 64px auto;
|
||||
|
||||
@media print {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,5 +8,4 @@
|
||||
@import "./youtube.css";
|
||||
@import "./media.css";
|
||||
@import "./code.css";
|
||||
|
||||
|
||||
@import "./print.css";
|
||||
|
||||
@ -4,10 +4,34 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.node-image, .node-video {
|
||||
.node-image, .node-video, .node-excalidraw, .node-drawio {
|
||||
&.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--mantine-color-body);
|
||||
border-radius: var(--mantine-radius-default);
|
||||
cursor: pointer;
|
||||
padding: 15px;
|
||||
height: 25px;
|
||||
|
||||
@mixin light {
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
border: 1px solid var(--mantine-color-dark-4);
|
||||
}
|
||||
}
|
||||
|
||||
.uploading-text {
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: var(--mantine-line-height-md);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,10 @@
|
||||
color: #adb5bd;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror .is-empty::before {
|
||||
@ -12,9 +16,17 @@
|
||||
color: #adb5bd;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror table .is-editor-empty:first-child::before,
|
||||
.ProseMirror table .is-empty::before {
|
||||
content: '';
|
||||
display: none;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
11
apps/client/src/features/editor/styles/print.css
Normal file
11
apps/client/src/features/editor/styles/print.css
Normal file
@ -0,0 +1,11 @@
|
||||
@media print {
|
||||
.mantine-AppShell-header,
|
||||
.mantine-AppShell-navbar,
|
||||
.mantine-AppShell-aside{
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mantine-AppShell-main {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
.tableWrapper {
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 3rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow-x: auto;
|
||||
& table {
|
||||
overflow-x: hidden;
|
||||
|
||||
@ -17,5 +17,9 @@
|
||||
&.ProseMirror-selectednode {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,10 +10,7 @@ import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import {
|
||||
usePageQuery,
|
||||
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";
|
||||
@ -21,7 +18,7 @@ import { updateTreeNodeName } from "@/features/page/tree/utils";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
@ -39,14 +36,18 @@ export function TitleEditor({
|
||||
editable,
|
||||
}: TitleEditorProps) {
|
||||
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
|
||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
|
||||
const {
|
||||
data: updatedPageData,
|
||||
mutate: updatePageMutation,
|
||||
status,
|
||||
} = useUpdatePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [activePageId, setActivePageId] = useState(pageId);
|
||||
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
@ -74,6 +75,7 @@ export function TitleEditor({
|
||||
onUpdate({ editor }) {
|
||||
const currentTitle = editor.getText();
|
||||
setDebouncedTitleState(currentTitle);
|
||||
setActivePageId(pageId);
|
||||
},
|
||||
editable: editable,
|
||||
content: title,
|
||||
@ -85,25 +87,30 @@ export function TitleEditor({
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTitle !== null) {
|
||||
updatePageMutation.mutate({
|
||||
if (debouncedTitle !== null && activePageId === pageId) {
|
||||
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,4 +1,4 @@
|
||||
import { Button, Divider, Group, Modal, ScrollArea } from "@mantine/core";
|
||||
import { Button, Divider, Group, Modal } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import React, { useState } from "react";
|
||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||
|
||||
@ -7,7 +7,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
name: z.string().trim().min(2).max(50),
|
||||
description: z.string().max(500),
|
||||
});
|
||||
|
||||
|
||||
@ -79,7 +79,7 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) {
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button type="submit">Edit</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
@ -1,69 +1,72 @@
|
||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
||||
import {Table, Group, Text, Anchor} from "@mantine/core";
|
||||
import {useGetGroupsQuery} from "@/features/group/queries/group-query";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||
import {Link} from "react-router-dom";
|
||||
import {IconGroupCircle} from "@/components/icons/icon-people-circle.tsx";
|
||||
|
||||
export default function GroupList() {
|
||||
const { data, isLoading } = useGetGroupsQuery();
|
||||
const {data, isLoading} = useGetGroupsQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Group</Table.Th>
|
||||
<Table.Th>Members</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((group, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
<Group gap="sm">
|
||||
<IconGroupCircle />
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{group.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{group.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
{group.memberCount} members
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.ScrollContainer minWidth={400}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Group</Table.Th>
|
||||
<Table.Th>Members</Table.Th>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((group, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IconGroupCircle/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{group.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{group.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
{group.memberCount} members
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
import { Group, Table, Text, Badge, Menu, ActionIcon } from "@mantine/core";
|
||||
import {Group, Table, Text, Badge, Menu, ActionIcon} from "@mantine/core";
|
||||
import {
|
||||
useGroupMembersQuery,
|
||||
useRemoveGroupMemberMutation,
|
||||
} from "@/features/group/queries/group-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {useParams} from "react-router-dom";
|
||||
import React from "react";
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import {IconDots} from "@tabler/icons-react";
|
||||
import {modals} from "@mantine/modals";
|
||||
import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
|
||||
export default function GroupMembersList() {
|
||||
const { groupId } = useParams();
|
||||
const { data, isLoading } = useGroupMembersQuery(groupId);
|
||||
const {groupId} = useParams();
|
||||
const {data, isLoading} = useGroupMembersQuery(groupId);
|
||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const {isAdmin} = useUserRole();
|
||||
|
||||
const onRemove = async (userId: string) => {
|
||||
const memberToRemove = {
|
||||
@ -34,72 +34,74 @@ export default function GroupMembersList() {
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
labels: {confirm: "Delete", cancel: "Cancel"},
|
||||
confirmProps: {color: "red"},
|
||||
onConfirm: () => onRemove(userId),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((user, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Badge variant="light">Active</Badge>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{isAdmin && (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" c="gray">
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
||||
Remove group member
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((user, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name}/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Badge variant="light">Active</Badge>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{isAdmin && (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" c="gray">
|
||||
<IconDots size={20} stroke={2}/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
||||
Remove group member
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -39,21 +39,23 @@ export function MultiGroupSelect({
|
||||
|
||||
useEffect(() => {
|
||||
if (groups) {
|
||||
const groupsData = groups?.items.map((group: IGroup) => {
|
||||
return {
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
};
|
||||
});
|
||||
const groupsData = groups?.items
|
||||
.filter((group: IGroup) => group.name.toLowerCase() !== 'everyone')
|
||||
.map((group: IGroup) => {
|
||||
return {
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out existing users by their ids
|
||||
// Filter out existing groups by their ids
|
||||
const filteredGroupData = groupsData.filter(
|
||||
(user) =>
|
||||
!data.find((existingUser) => existingUser.value === user.value),
|
||||
(group) =>
|
||||
!data.find((existingGroup) => existingGroup.value === group.value),
|
||||
);
|
||||
|
||||
// Combine existing data with new search data
|
||||
setData((prevData) => [...prevData, ...filteredGroupData]);
|
||||
setData((prevData) => [... prevData, ... filteredGroupData]);
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
|
||||
@ -3,8 +3,8 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { IGroup } from "@/features/group/types/group.types";
|
||||
} from '@tanstack/react-query';
|
||||
import { IGroup } from '@/features/group/types/group.types';
|
||||
import {
|
||||
addGroupMember,
|
||||
createGroup,
|
||||
@ -14,43 +14,41 @@ import {
|
||||
getGroups,
|
||||
removeGroupMember,
|
||||
updateGroup,
|
||||
} from "@/features/group/services/group-service";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { QueryParams } from "@/lib/types.ts";
|
||||
} from '@/features/group/services/group-service';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { QueryParams } from '@/lib/types.ts';
|
||||
|
||||
export function useGetGroupsQuery(
|
||||
params?: QueryParams,
|
||||
params?: QueryParams
|
||||
): UseQueryResult<any, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["groups", params],
|
||||
queryKey: ['groups', params],
|
||||
queryFn: () => getGroups(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["groups", groupId],
|
||||
queryKey: ['group', groupId],
|
||||
queryFn: () => getGroupById(groupId),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupMembersQuery(groupId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["groupMembers", groupId],
|
||||
queryFn: () => getGroupMembers(groupId),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateGroupMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IGroup, Error, Partial<IGroup>>({
|
||||
mutationFn: (data) => createGroup(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: "Group created successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['groups'],
|
||||
});
|
||||
|
||||
notifications.show({ message: 'Group created successfully' });
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({ message: "Failed to create group", color: "red" });
|
||||
notifications.show({ message: 'Failed to create group', color: 'red' });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -61,14 +59,14 @@ export function useUpdateGroupMutation() {
|
||||
return useMutation<IGroup, Error, Partial<IGroup>>({
|
||||
mutationFn: (data) => updateGroup(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Group updated successfully" });
|
||||
notifications.show({ message: 'Group updated successfully' });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["group", variables.groupId],
|
||||
queryKey: ['group', variables.groupId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -79,36 +77,46 @@ export function useDeleteGroupMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (groupId: string) => deleteGroup({ groupId }),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Group deleted successfully" });
|
||||
notifications.show({ message: 'Group deleted successfully' });
|
||||
|
||||
const groups = queryClient.getQueryData(["groups"]) as any;
|
||||
const groups = queryClient.getQueryData(['groups']) as any;
|
||||
if (groups) {
|
||||
groups.items?.filter((group: IGroup) => group.id !== variables);
|
||||
queryClient.setQueryData(["groups"], groups);
|
||||
groups.items = groups.items?.filter(
|
||||
(group: IGroup) => group.id !== variables
|
||||
);
|
||||
queryClient.setQueryData(['groups'], groups);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupMembersQuery(groupId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['groupMembers', groupId],
|
||||
queryFn: () => getGroupMembers(groupId),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddGroupMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
|
||||
mutationFn: (data) => addGroupMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Added successfully" });
|
||||
notifications.show({ message: 'Added successfully' });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["groupMembers", variables.groupId],
|
||||
queryKey: ['groupMembers', variables.groupId],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: "Failed to add group members",
|
||||
color: "red",
|
||||
message: 'Failed to add group members',
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -127,14 +135,14 @@ export function useRemoveGroupMemberMutation() {
|
||||
>({
|
||||
mutationFn: (data) => removeGroupMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Removed successfully" });
|
||||
notifications.show({ message: 'Removed successfully' });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["groupMembers", variables.groupId],
|
||||
queryKey: ['groupMembers', variables.groupId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
const errorMessage = error['response']?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: 'red' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ import {
|
||||
usePageHistoryListQuery,
|
||||
usePageHistoryQuery,
|
||||
} from "@/features/page-history/queries/page-history-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import HistoryItem from "@/features/page-history/components/history-item";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
|
||||
@ -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,16 +2,18 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsHorizontal,
|
||||
IconDots,
|
||||
IconFileExport,
|
||||
IconHistory,
|
||||
IconLink,
|
||||
IconMessage,
|
||||
IconPrinter,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@ -21,6 +23,8 @@ import { extractPageSlugId } from "@/lib";
|
||||
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;
|
||||
@ -57,6 +61,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
});
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@ -66,6 +72,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
notifications.show({ message: "Link copied" });
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const openHistoryModal = () => {
|
||||
setHistoryModalOpen(true);
|
||||
};
|
||||
@ -75,55 +87,80 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<>
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
||||
<IconDots size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} stroke={2} />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} stroke={2} />}>
|
||||
<Group wrap="nowrap">
|
||||
<PageWidthToggle label="Full width" />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||
<Group wrap="nowrap">
|
||||
<PageWidthToggle label="Full width" />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} stroke={2} />}
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
Page history
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} />}
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
Page history
|
||||
</Menu.Item>
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color={"red"}
|
||||
leftSection={<IconTrash size={16} stroke={2} />}
|
||||
onClick={handleDeletePage}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
onClick={openExportModal}
|
||||
>
|
||||
Export
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconPrinter size={16} />}
|
||||
onClick={handlePrint}
|
||||
>
|
||||
Print PDF
|
||||
</Menu.Item>
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color={"red"}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleDeletePage}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<ExportModal
|
||||
type="page"
|
||||
id={page.id}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,4 +8,8 @@
|
||||
top: var(--app-shell-header-offset, 0rem);
|
||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
104
apps/client/src/features/page/components/page-export-modal.tsx
Normal file
104
apps/client/src/features/page/components/page-export-modal.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
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";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
interface PageExportModalProps {
|
||||
pageId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PageExportModal({
|
||||
pageId,
|
||||
open,
|
||||
onClose,
|
||||
}: PageExportModalProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
await exportPage({ pageId: pageId, format });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: "Export failed:" + err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (format: ExportFormat) => {
|
||||
setFormat(format);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size={500}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>Export page</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">Format</Text>
|
||||
</div>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
|
||||
</Group>
|
||||
|
||||
<Group justify="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
|
||||
</Button>
|
||||
<Button onClick={handleExport}>Export</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportFormatSelection {
|
||||
format: ExportFormat;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
]}
|
||||
defaultValue={format}
|
||||
onChange={onChange}
|
||||
styles={{ wrapper: { maxWidth: 120 } }}
|
||||
comboboxProps={{ width: "120" }}
|
||||
allowDeselect={false}
|
||||
withCheckIcon={false}
|
||||
aria-label="Select export format"
|
||||
/>
|
||||
);
|
||||
}
|
||||
150
apps/client/src/features/page/components/page-import-modal.tsx
Normal file
150
apps/client/src/features/page/components/page-import-modal.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconFileCode,
|
||||
IconMarkdown,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { importPage } from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { buildTree } from "@/features/page/tree/utils";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import React from "react";
|
||||
|
||||
interface PageImportModalProps {
|
||||
spaceId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PageImportModal({
|
||||
spaceId,
|
||||
open,
|
||||
onClose,
|
||||
}: PageImportModalProps) {
|
||||
return (
|
||||
<>
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size={600}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>Import pages</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<ImportFormatSelection spaceId={spaceId} onClose={onClose} />
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImportFormatSelection {
|
||||
spaceId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
|
||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||
if (!selectedFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
const alert = notifications.show({
|
||||
title: "Importing pages",
|
||||
message: "Page import is in progress. Please do not close this tab.",
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const pages: IPage[] = [];
|
||||
let pageCount = 0;
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
try {
|
||||
const page = await importPage(file, spaceId);
|
||||
pages.push(page);
|
||||
pageCount += 1;
|
||||
} catch (err) {
|
||||
console.log("Failed to import page", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (pages?.length > 0 && pageCount > 0) {
|
||||
const newTreeNodes = buildTree(pages);
|
||||
const fullTree = treeData.concat(newTreeNodes);
|
||||
|
||||
if (newTreeNodes?.length && fullTree?.length > 0) {
|
||||
setTreeData(fullTree);
|
||||
}
|
||||
|
||||
const pageCountText = pageCount === 1 ? "1 page" : `${pageCount} pages`;
|
||||
|
||||
notifications.update({
|
||||
id: alert,
|
||||
color: "teal",
|
||||
title: `Successfully imported ${pageCountText}`,
|
||||
message: "Your import is complete.",
|
||||
icon: <IconCheck size={18} />,
|
||||
loading: false,
|
||||
autoClose: 5000,
|
||||
});
|
||||
} else {
|
||||
notifications.update({
|
||||
id: alert,
|
||||
color: "red",
|
||||
title: `Failed to import pages`,
|
||||
message: "Unable to import pages. Please try again.",
|
||||
icon: <IconX size={18} />,
|
||||
loading: false,
|
||||
autoClose: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={2}>
|
||||
<FileButton onChange={handleFileUpload} accept=".md" multiple>
|
||||
{(props) => (
|
||||
<Button
|
||||
justify="start"
|
||||
variant="default"
|
||||
leftSection={<IconMarkdown size={18} />}
|
||||
{...props}
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton onChange={handleFileUpload} accept="text/html" multiple>
|
||||
{(props) => (
|
||||
<Button
|
||||
justify="start"
|
||||
variant="default"
|
||||
leftSection={<IconFileCode size={18} />}
|
||||
{...props}
|
||||
>
|
||||
HTML
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,13 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IExportPageParams,
|
||||
IMovePage,
|
||||
IPage,
|
||||
IPageInput,
|
||||
SidebarPagesParams,
|
||||
} from "@/features/page/types/page.types";
|
||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/create", data);
|
||||
@ -53,18 +55,49 @@ export async function getRecentChanges(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function uploadFile(file: File, pageId: string) {
|
||||
export async function exportPage(data: IExportPageParams): Promise<void> {
|
||||
const req = await api.post("/pages/export", data, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
const fileName = req?.headers["content-disposition"]
|
||||
.split("filename=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
saveAs(req.data, decodeURIComponent(fileName));
|
||||
}
|
||||
|
||||
export async function importPage(file: File, spaceId: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("spaceId", spaceId);
|
||||
formData.append("file", file);
|
||||
|
||||
const req = await api.post<IPage>("/pages/import", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
pageId: string,
|
||||
attachmentId?: string,
|
||||
): Promise<IAttachment> {
|
||||
const formData = new FormData();
|
||||
if (attachmentId) {
|
||||
formData.append("attachmentId", attachmentId);
|
||||
}
|
||||
formData.append("pageId", pageId);
|
||||
formData.append("file", file);
|
||||
|
||||
// should be file endpoint
|
||||
const req = await api.post<IAttachment>("/files/upload", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
// console.log("req", req);
|
||||
|
||||
return req;
|
||||
return req as unknown as IAttachment;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
@ -207,7 +213,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
overscanCount={10}
|
||||
dndRootElement={rootElement.current}
|
||||
onToggle={() => {
|
||||
setOpenTreeNodes(treeApiRef.current.openState);
|
||||
setOpenTreeNodes(treeApiRef.current?.openState);
|
||||
}}
|
||||
initialOpenState={openTreeNodes}
|
||||
>
|
||||
@ -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,54 +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={() =>
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
|
||||
export function useTreeMutation<T>(spaceId: string) {
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
@ -31,6 +32,8 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const { spaceSlug } = useParams();
|
||||
const { pageSlug } = useParams();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
|
||||
const payload: { spaceId: string; parentPageId?: string } = {
|
||||
@ -69,10 +72,22 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
tree.create({ parentId, index, data });
|
||||
setData(tree.data);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: spaceId,
|
||||
payload: {
|
||||
parentId,
|
||||
index,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
|
||||
const pageUrl = buildPageUrl(
|
||||
spaceSlug,
|
||||
createdPage.slugId,
|
||||
createdPage.title,
|
||||
createdPage.title
|
||||
);
|
||||
navigate(pageUrl);
|
||||
return data;
|
||||
@ -100,7 +115,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
: tree.data;
|
||||
|
||||
// if there is a parentId, tree.find(args.parentId).children returns a SimpleNode array
|
||||
// we have to access the node differently viq currentTreeData[args.index]?.data?.position
|
||||
// we have to access the node differently via currentTreeData[args.index]?.data?.position
|
||||
// this makes it possible to correctly sort children of a parent node that is not the root
|
||||
|
||||
const afterPosition =
|
||||
@ -142,7 +157,7 @@ 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({
|
||||
@ -162,6 +177,19 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
|
||||
try {
|
||||
movePageMutation.mutateAsync(payload);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "moveTreeNode",
|
||||
spaceId: spaceId,
|
||||
payload: {
|
||||
id: draggedNodeId,
|
||||
parentId: args.parentId,
|
||||
index: args.index,
|
||||
position: newPosition,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
} catch (error) {
|
||||
console.error("Error moving page:", error);
|
||||
}
|
||||
@ -182,12 +210,26 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
try {
|
||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||
|
||||
if (tree.find(args.ids[0])) {
|
||||
tree.drop({ id: args.ids[0] });
|
||||
setData(tree.data);
|
||||
const node = tree.find(args.ids[0]);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(getSpaceUrl(spaceSlug));
|
||||
tree.drop({ id: args.ids[0] });
|
||||
setData(tree.data);
|
||||
|
||||
// navigate only if the current url is same as the deleted page
|
||||
if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) {
|
||||
navigate(getSpaceUrl(spaceSlug));
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "deleteTreeNode",
|
||||
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 {
|
||||
|
||||
@ -100,6 +100,28 @@ export const updateTreeNodeIcon = (
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteTreeNode = (
|
||||
nodes: SpaceTreeNode[],
|
||||
nodeId: string,
|
||||
): SpaceTreeNode[] => {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
children: deleteTreeNode(node.children, nodeId),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
})
|
||||
.filter((node) => node !== null);
|
||||
};
|
||||
|
||||
|
||||
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
||||
const nodeMap = {};
|
||||
let result: SpaceTreeNode[] = [];
|
||||
|
||||
@ -44,3 +44,14 @@ export interface IPageInput {
|
||||
coverPhoto: string;
|
||||
position: string;
|
||||
}
|
||||
|
||||
export interface IExportPageParams {
|
||||
pageId: string;
|
||||
format: ExportFormat;
|
||||
includeChildren?: boolean;
|
||||
}
|
||||
|
||||
export enum ExportFormat {
|
||||
HTML = "html",
|
||||
Markdown = "markdown",
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { Group, Center, Text } from "@mantine/core";
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconFileDescription, IconSearch } from "@tabler/icons-react";
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user