mirror of
https://github.com/docmost/docmost.git
synced 2026-05-10 16:24:05 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 404e6c0b2f | |||
| 900e367677 | |||
| ace00a0b0a |
+2
-3
@@ -1,6 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.git
|
.git
|
||||||
|
.gitignore
|
||||||
dist
|
dist
|
||||||
/data
|
data
|
||||||
.env*
|
|
||||||
.nx
|
|
||||||
|
|||||||
+1
-7
@@ -46,10 +46,4 @@ DRAWIO_URL=
|
|||||||
DISABLE_TELEMETRY=false
|
DISABLE_TELEMETRY=false
|
||||||
|
|
||||||
# Enable debug logging in production (default: false)
|
# Enable debug logging in production (default: false)
|
||||||
DEBUG_MODE=false
|
DEBUG_MODE=false
|
||||||
|
|
||||||
# Log database queries
|
|
||||||
DEBUG_DB=false
|
|
||||||
|
|
||||||
# Log http requests
|
|
||||||
LOG_HTTP=false
|
|
||||||
+3
-3
@@ -1,14 +1,13 @@
|
|||||||
FROM node:22-slim AS base
|
FROM node:22-slim AS base
|
||||||
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
||||||
|
|
||||||
RUN npm install -g pnpm@10.4.0
|
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@10.4.0
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
@@ -32,11 +31,12 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
|
|||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
COPY --from=builder /app/pnpm*.yaml /app/
|
COPY --from=builder /app/pnpm*.yaml /app/
|
||||||
COPY --from=builder /app/.npmrc /app/.npmrc
|
|
||||||
|
|
||||||
# Copy patches
|
# Copy patches
|
||||||
COPY --from=builder /app/patches /app/patches
|
COPY --from=builder /app/patches /app/patches
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@10.4.0
|
||||||
|
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|||||||
+32
-29
@@ -10,49 +10,52 @@
|
|||||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@casl/ability": "^6.7.2",
|
||||||
"@casl/react": "^4.0.0",
|
"@casl/react": "^4.0.0",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "0.18.0-c158187",
|
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||||
"@mantine/core": "^8.3.12",
|
"@mantine/core": "^8.1.3",
|
||||||
"@mantine/dates": "^8.3.12",
|
"@mantine/dates": "^8.3.2",
|
||||||
"@mantine/form": "^8.3.12",
|
"@mantine/form": "^8.1.3",
|
||||||
"@mantine/hooks": "^8.3.12",
|
"@mantine/hooks": "^8.1.3",
|
||||||
"@mantine/modals": "^8.3.12",
|
"@mantine/modals": "^8.1.3",
|
||||||
"@mantine/notifications": "^8.3.12",
|
"@mantine/notifications": "^8.1.3",
|
||||||
"@mantine/spotlight": "^8.3.12",
|
"@mantine/spotlight": "^8.1.3",
|
||||||
"@tabler/icons-react": "^3.36.1",
|
"@tabler/icons-react": "^3.34.0",
|
||||||
"@tanstack/react-query": "^5.90.17",
|
"@tanstack/react-query": "^5.80.6",
|
||||||
|
"@tiptap/extension-character-count": "^2.10.3",
|
||||||
"alfaaz": "^1.1.0",
|
"alfaaz": "^1.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"highlightjs-sap-abap": "^0.3.0",
|
"highlightjs-sap-abap": "^0.3.0",
|
||||||
"i18next": "^23.16.8",
|
"i18next": "^23.14.0",
|
||||||
"i18next-http-backend": "^2.7.3",
|
"i18next-http-backend": "^2.6.1",
|
||||||
"jotai": "^2.16.2",
|
"jotai": "^2.12.5",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"katex": "0.16.27",
|
"katex": "0.16.22",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"mantine-form-zod-resolver": "^1.3.0",
|
"mantine-form-zod-resolver": "^1.3.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.11.0",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "^1.255.1",
|
"posthog-js": "^1.255.1",
|
||||||
"react": "^19.2.3",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.17",
|
"react-clear-modal": "^2.0.15",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "^1.0.7",
|
"react-drawio": "^1.0.1",
|
||||||
"react-error-boundary": "^6.1.0",
|
"react-error-boundary": "^4.1.2",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-i18next": "^15.0.1",
|
"react-i18next": "^15.0.1",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.0.1",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.2",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.1",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
@@ -63,13 +66,13 @@
|
|||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/katex": "^0.16.7",
|
"@types/katex": "^0.16.7",
|
||||||
"@types/node": "22.19.1",
|
"@types/node": "22.19.1",
|
||||||
"@types/react": "^19.2.9",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.2",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.26",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
"globals": "^15.13.0",
|
"globals": "^15.13.0",
|
||||||
"optics-ts": "^2.4.1",
|
"optics-ts": "^2.4.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
|
|||||||
@@ -328,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Upload any image from your device.",
|
"Upload any image from your device.": "Upload any image from your device.",
|
||||||
"Upload any video from your device.": "Upload any video from your device.",
|
"Upload any video from your device.": "Upload any video from your device.",
|
||||||
"Upload any file from your device.": "Upload any file from your device.",
|
"Upload any file from your device.": "Upload any file from your device.",
|
||||||
"Uploading {{name}}": "Uploading {{name}}",
|
|
||||||
"Uploading file": "Uploading file",
|
|
||||||
"Table": "Table",
|
"Table": "Table",
|
||||||
"Insert a table.": "Insert a table.",
|
"Insert a table.": "Insert a table.",
|
||||||
"Insert collapsible block.": "Insert collapsible block.",
|
"Insert collapsible block.": "Insert collapsible block.",
|
||||||
|
|||||||
@@ -13,21 +13,21 @@
|
|||||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。",
|
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。",
|
||||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "このユーザをスペースから削除してもよろしいですか? ユーザはこのスペースへのアクセス権をすべて失います。",
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "このユーザをスペースから削除してもよろしいですか? ユーザはこのスペースへのアクセス権をすべて失います。",
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。",
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。",
|
||||||
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになれます",
|
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになることができます",
|
||||||
"Can create and edit pages in space.": "スペース内のページを作成・編集できます",
|
"Can create and edit pages in space.": "スペース内のページを作成および編集できます。",
|
||||||
"Can edit": "編集可能",
|
"Can edit": "編集可能",
|
||||||
"Can manage workspace": "ワークスペースを管理できます",
|
"Can manage workspace": "ワークスペースを管理できます",
|
||||||
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
|
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが、削除はできません",
|
||||||
"Can view": "閲覧可能",
|
"Can view": "閲覧可能",
|
||||||
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
|
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが、編集はできません。",
|
||||||
"Cancel": "キャンセル",
|
"Cancel": "キャンセル",
|
||||||
"Change email": "メールアドレスの変更",
|
"Change email": "メールアドレスの変更",
|
||||||
"Change password": "パスワードの変更",
|
"Change password": "パスワードの変更",
|
||||||
"Change photo": "画像の変更",
|
"Change photo": "画像の変更",
|
||||||
"Choose a role": "ロールを選んでください",
|
"Choose a role": "ロールを選んでください",
|
||||||
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
|
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください。",
|
||||||
"Choose your preferred interface language.": "お好みの言語を選択してください",
|
"Choose your preferred interface language.": "お好みのインターフェース言語を選択してください。",
|
||||||
"Choose your preferred page width.": "お好みのページ幅を選択してください",
|
"Choose your preferred page width.": "左右の余白を縮小する場合はオンにしてください。",
|
||||||
"Confirm": "確認",
|
"Confirm": "確認",
|
||||||
"Copy link": "リンクをコピー",
|
"Copy link": "リンクをコピー",
|
||||||
"Create": "新規作成",
|
"Create": "新規作成",
|
||||||
@@ -40,24 +40,24 @@
|
|||||||
"Date": "日付",
|
"Date": "日付",
|
||||||
"Delete": "削除",
|
"Delete": "削除",
|
||||||
"Delete group": "グループを削除",
|
"Delete group": "グループを削除",
|
||||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?子ページとページ履歴も削除されます。この操作は取り消せません。",
|
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?この操作により、子ページおよびページ履歴が削除されます。この操作は元に戻せません。",
|
||||||
"Description": "説明",
|
"Description": "説明",
|
||||||
"Details": "詳細",
|
"Details": "詳細",
|
||||||
"e.g ACME": "例: 山田太郎",
|
"e.g ACME": "例: 山田太郎",
|
||||||
"e.g ACME Inc": "例: 株式会社サンプル",
|
"e.g ACME Inc": "例: 株式会社サンプル",
|
||||||
"e.g Developers": "例: エンジニア",
|
"e.g Developers": "例: エンジニア",
|
||||||
"e.g Group for developers": "例: 開発チーム",
|
"e.g Group for developers": "例: エンジニアグループ",
|
||||||
"e.g product": "例: product",
|
"e.g product": "例: product",
|
||||||
"e.g Product Team": "例: プロダクトチーム",
|
"e.g Product Team": "例: 製品チーム",
|
||||||
"e.g Sales": "例: 営業部",
|
"e.g Sales": "例: 営業",
|
||||||
"e.g Space for product team": "例: プロダクトチーム用スペース",
|
"e.g Space for product team": "例: 製品チームのスペース",
|
||||||
"e.g Space for sales team to collaborate": "例: 営業チーム用スペース",
|
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
|
||||||
"Edit": "編集",
|
"Edit": "編集",
|
||||||
"Read": "閲覧",
|
"Read": "読む",
|
||||||
"Edit group": "グループを編集",
|
"Edit group": "グループを編集",
|
||||||
"Email": "メールアドレス",
|
"Email": "メールアドレス",
|
||||||
"Enter a strong password": "強力なパスワードを入力してください",
|
"Enter a strong password": "強力なパスワードを入力してください",
|
||||||
"Enter valid email addresses separated by comma or space max_50": "メールアドレスをカンマまたはスペース区切りで入力(最大50件)",
|
"Enter valid email addresses separated by comma or space max_50": "有効なメールアドレスをカンマまたはスペースで区切って入力してください(最大 50 個)",
|
||||||
"enter valid emails addresses": "有効なメールアドレスを入力してください",
|
"enter valid emails addresses": "有効なメールアドレスを入力してください",
|
||||||
"Enter your current password": "現在のパスワードを入力してください",
|
"Enter your current password": "現在のパスワードを入力してください",
|
||||||
"enter your full name": "氏名を入力してください",
|
"enter your full name": "氏名を入力してください",
|
||||||
@@ -81,18 +81,18 @@
|
|||||||
"Group description": "グループ説明",
|
"Group description": "グループ説明",
|
||||||
"Group name": "グループ名",
|
"Group name": "グループ名",
|
||||||
"Groups": "グループ",
|
"Groups": "グループ",
|
||||||
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
|
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます。",
|
||||||
"Home": "ホーム",
|
"Home": "ホーム",
|
||||||
"Import pages": "ページをインポート",
|
"Import pages": "ページをインポート",
|
||||||
"Import pages & space settings": "ページとスペース設定をインポート",
|
"Import pages & space settings": "ページとスペース設定をインポート",
|
||||||
"Importing pages": "ページをインポートしています",
|
"Importing pages": "ページをインポートしています",
|
||||||
"invalid invitation link": "無効な招待リンクです",
|
"invalid invitation link": "招待リンクが間違っています",
|
||||||
"Invitation signup": "招待登録",
|
"Invitation signup": "招待登録",
|
||||||
"Invite by email": "メールアドレスで招待する",
|
"Invite by email": "メールアドレスで招待する",
|
||||||
"Invite members": "メンバーを招待する",
|
"Invite members": "メンバーを招待する",
|
||||||
"Invite new members": "新しいメンバーを招待する",
|
"Invite new members": "新しいメンバーを招待する",
|
||||||
"Invited members who are yet to accept their invitation will appear here.": "招待を承諾していないメンバーがここに表示されます",
|
"Invited members who are yet to accept their invitation will appear here.": "招待をまだ承諾していないメンバーはここに表示されます。",
|
||||||
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセスできます",
|
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーは、グループがアクセスできるスペースにアクセス権が付与されます",
|
||||||
"Join the workspace": "ワークスペースに参加",
|
"Join the workspace": "ワークスペースに参加",
|
||||||
"Language": "言語",
|
"Language": "言語",
|
||||||
"Light": "ライト",
|
"Light": "ライト",
|
||||||
@@ -113,20 +113,20 @@
|
|||||||
"New page": "新規ページ",
|
"New page": "新規ページ",
|
||||||
"New password": "新しいパスワード",
|
"New password": "新しいパスワード",
|
||||||
"No group found": "グループが見つかりません",
|
"No group found": "グループが見つかりません",
|
||||||
"No page history saved yet.": "ページ履歴がありません",
|
"No page history saved yet.": "まだページの履歴が保存されていません。",
|
||||||
"No pages yet": "ページがありません",
|
"No pages yet": "ページがありません",
|
||||||
"No results found...": "結果が見つかりません",
|
"No results found...": "結果が見つかりませんでした...",
|
||||||
"No user found": "ユーザーが見つかりません",
|
"No user found": "ユーザがいません",
|
||||||
"Overview": "概要",
|
"Overview": "概要",
|
||||||
"Owner": "所有者",
|
"Owner": "所有者",
|
||||||
"page": "ページ",
|
"page": "ページ",
|
||||||
"Page deleted successfully": "ページを削除しました",
|
"Page deleted successfully": "ページが正常に削除されました",
|
||||||
"Page history": "ページ履歴",
|
"Page history": "ページの履歴",
|
||||||
"Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください",
|
"Page import is in progress. Please do not close this tab.": "ページのインポートが進行中です。このタブを閉じないでください。",
|
||||||
"Pages": "ページ",
|
"Pages": "ページ",
|
||||||
"pages": "ページ",
|
"pages": "ページ",
|
||||||
"Password": "パスワード",
|
"Password": "パスワード",
|
||||||
"Password changed successfully": "パスワードを変更しました",
|
"Password changed successfully": "パスワードが正常に変更されました",
|
||||||
"Pending": "保留中",
|
"Pending": "保留中",
|
||||||
"Please confirm your action": "アクションを確認してください",
|
"Please confirm your action": "アクションを確認してください",
|
||||||
"Preferences": "設定",
|
"Preferences": "設定",
|
||||||
@@ -143,95 +143,95 @@
|
|||||||
"Search for groups": "グループを検索",
|
"Search for groups": "グループを検索",
|
||||||
"Search for users": "ユーザーを検索",
|
"Search for users": "ユーザーを検索",
|
||||||
"Search for users and groups": "ユーザーとグループを検索",
|
"Search for users and groups": "ユーザーとグループを検索",
|
||||||
"Search...": "検索",
|
"Search...": "検索する語句を入力",
|
||||||
"Select language": "言語を選択",
|
"Select language": "言語を選択",
|
||||||
"Select role": "ロールを選択",
|
"Select role": "ロールを選択",
|
||||||
"Select role to assign to all invited members": "招待するメンバーに割り当てるロールを選択",
|
"Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください",
|
||||||
"Select theme": "テーマを選択",
|
"Select theme": "テーマを選択",
|
||||||
"Send invitation": "招待を送る",
|
"Send invitation": "招待を送る",
|
||||||
"Invitation sent": "招待を送信しました",
|
"Invitation sent": "招待が送信されました",
|
||||||
"Settings": "設定",
|
"Settings": "設定",
|
||||||
"Setup workspace": "ワークスペースを設定する",
|
"Setup workspace": "ワークスペースを設定する",
|
||||||
"Sign In": "サインイン",
|
"Sign In": "サインイン",
|
||||||
"Sign Up": "新規登録",
|
"Sign Up": "アカウント登録",
|
||||||
"Slug": "スラッグ(URL識別子)",
|
"Slug": "Slug (URL用文字列)",
|
||||||
"Space": "スペース",
|
"Space": "スペース",
|
||||||
"Space description": "スペース説明",
|
"Space description": "スペース説明",
|
||||||
"Space menu": "スペースメニュー",
|
"Space menu": "スペースメニュー",
|
||||||
"Space name": "スペース名",
|
"Space name": "スペース名",
|
||||||
"Space settings": "スペース設定",
|
"Space settings": "スペース設定",
|
||||||
"Space slug": "スペースのスラッグ(URL識別子)",
|
"Space slug": "スペースのSlug (URL用文字列)",
|
||||||
"Spaces": "スペース",
|
"Spaces": "スペース",
|
||||||
"Spaces you belong to": "所属しているスペース",
|
"Spaces you belong to": "所属しているスペース",
|
||||||
"No space found": "スペースが見つかりません",
|
"No space found": "スペースが見つかりません",
|
||||||
"Search for spaces": "スペースを検索",
|
"Search for spaces": "スペースを検索",
|
||||||
"Start typing to search...": "入力して検索",
|
"Start typing to search...": "検索を開始するには入力してください...",
|
||||||
"Status": "ステータス",
|
"Status": "ステータス",
|
||||||
"Successfully imported": "インポートしました",
|
"Successfully imported": "インポートに成功しました",
|
||||||
"Successfully restored": "復元しました",
|
"Successfully restored": "正常に復元されました",
|
||||||
"System settings": "システム設定",
|
"System settings": "システム設定",
|
||||||
"Theme": "テーマ",
|
"Theme": "テーマ",
|
||||||
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力してください",
|
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力する必要があります。",
|
||||||
"Toggle full page width": "ページ幅を切り替え",
|
"Toggle full page width": "ページ幅を切り替える",
|
||||||
"Unable to import pages. Please try again.": "ページをインポートできませんでした。もう一度お試しください",
|
"Unable to import pages. Please try again.": "ページをインポートできません。もう一度お試しください。",
|
||||||
"untitled": "無題",
|
"untitled": "無題",
|
||||||
"Untitled": "無題",
|
"Untitled": "無題",
|
||||||
"Updated successfully": "更新しました",
|
"Updated successfully": "正常に更新されました",
|
||||||
"User": "ユーザー",
|
"User": "ユーザー",
|
||||||
"Workspace": "ワークスペース",
|
"Workspace": "ワークスペース",
|
||||||
"Workspace Name": "ワークスペース名",
|
"Workspace Name": "ワークスペース名",
|
||||||
"Workspace settings": "ワークスペース設定",
|
"Workspace settings": "ワークスペース設定",
|
||||||
"You can change your password here.": "パスワードを変更できます",
|
"You can change your password here.": "パスワードを変更できます。",
|
||||||
"Your Email": "メールアドレス",
|
"Your Email": "メールアドレス",
|
||||||
"Your import is complete.": "インポートが完了しました",
|
"Your import is complete.": "インポートが完了しました。",
|
||||||
"Your name": "名前",
|
"Your name": "名前",
|
||||||
"Your Name": "名前",
|
"Your Name": "名前",
|
||||||
"Your password": "パスワード",
|
"Your password": "パスワード",
|
||||||
"Your password must be a minimum of 8 characters.": "パスワードは8文字以上にしてください",
|
"Your password must be a minimum of 8 characters.": "パスワードは最低 8 文字必要です。",
|
||||||
"Sidebar toggle": "サイドバー切り替え",
|
"Sidebar toggle": "サイドバー切り替え",
|
||||||
"Comments": "コメント",
|
"Comments": "コメント",
|
||||||
"404 page not found": "404 ページが見つかりません",
|
"404 page not found": "404 ページが見つかりません",
|
||||||
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません",
|
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません。",
|
||||||
"Take me back to homepage": "ホームに戻る",
|
"Take me back to homepage": "ホームに戻る",
|
||||||
"Forgot password": "パスワードを忘れた",
|
"Forgot password": "パスワードを忘れた",
|
||||||
"Forgot your password?": "パスワードを忘れましたか?",
|
"Forgot your password?": "パスワードを忘れましたか?",
|
||||||
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセット用のリンクをメールに送信しました。受信トレイを確認してください",
|
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセットリンクがあなたのメールアドレスに送信されました。受信箱を確認してください。",
|
||||||
"Send reset link": "リセットリンクを送信",
|
"Send reset link": "リセットリンクを送る",
|
||||||
"Password reset": "パスワードリセット",
|
"Password reset": "パスワードリセット",
|
||||||
"Your new password": "新しいパスワード",
|
"Your new password": "新しいパスワード",
|
||||||
"Set password": "パスワードを設定",
|
"Set password": "パスワードを設定",
|
||||||
"Write a comment": "コメントを書く",
|
"Write a comment": "コメントを書く",
|
||||||
"Reply...": "返信...",
|
"Reply...": "返信...",
|
||||||
"Error loading comments.": "コメントの読み込みに失敗しました",
|
"Error loading comments.": "コメントの読み込み中にエラーが発生しました。",
|
||||||
"No comments yet.": "コメントがありません",
|
"No comments yet.": "コメントがありません。",
|
||||||
"Edit comment": "コメントを編集する",
|
"Edit comment": "コメントを編集する",
|
||||||
"Delete comment": "コメントを削除する",
|
"Delete comment": "コメントを削除する",
|
||||||
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
|
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
|
||||||
"Comment created successfully": "コメントを作成しました",
|
"Comment created successfully": "コメントが作成されました",
|
||||||
"Error creating comment": "コメントの作成に失敗しました",
|
"Error creating comment": "コメントの作成中にエラーが発生しました",
|
||||||
"Comment updated successfully": "コメントを更新しました",
|
"Comment updated successfully": "コメントが更新されました",
|
||||||
"Failed to update comment": "コメントの更新に失敗しました",
|
"Failed to update comment": "コメントの更新に失敗しました",
|
||||||
"Comment deleted successfully": "コメントを削除しました",
|
"Comment deleted successfully": "コメントが削除されました",
|
||||||
"Failed to delete comment": "コメントの削除に失敗しました",
|
"Failed to delete comment": "コメントの削除に失敗しました",
|
||||||
"Comment resolved successfully": "コメントを解決しました",
|
"Comment resolved successfully": "コメントが解決されました",
|
||||||
"Comment re-opened successfully": "コメントを再開しました",
|
"Comment re-opened successfully": "コメントが再開されました",
|
||||||
"Comment unresolved successfully": "コメントを未解決に戻しました",
|
"Comment unresolved successfully": "コメントが再解決されました",
|
||||||
"Failed to resolve comment": "コメントの解決に失敗しました",
|
"Failed to resolve comment": "コメントの解決に失敗しました",
|
||||||
"Resolve comment": "コメントを解決",
|
"Resolve comment": "コメントを解決",
|
||||||
"Unresolve comment": "コメントを未解決に戻す",
|
"Unresolve comment": "コメントを再解決",
|
||||||
"Resolve Comment Thread": "コメントスレッドを解決",
|
"Resolve Comment Thread": "コメントスレッドを解決",
|
||||||
"Unresolve Comment Thread": "コメントスレッドを未解決に戻す",
|
"Unresolve Comment Thread": "コメントスレッドを再解決",
|
||||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか?完了としてマークされます",
|
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか? これにより完了としてマークされます。",
|
||||||
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを未解決に戻しますか?",
|
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを再解決しますか?",
|
||||||
"Resolved": "解決済",
|
"Resolved": "解決済",
|
||||||
"No active comments.": "アクティブなコメントはありません",
|
"No active comments.": "アクティブなコメントはありません。",
|
||||||
"No resolved comments.": "解決済みのコメントはありません",
|
"No resolved comments.": "解決されたコメントはありません。",
|
||||||
"Revoke invitation": "招待を取り消す",
|
"Revoke invitation": "招待を取り消す",
|
||||||
"Revoke": "取り消す",
|
"Revoke": "取り消す",
|
||||||
"Don't": "取り消さない",
|
"Don't": "取り消さない",
|
||||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですか?ユーザーはワークスペースに参加できなくなります",
|
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですか? ユーザはワークスペースに参加できなくなります。",
|
||||||
"Resend invitation": "招待を再度送る",
|
"Resend invitation": "招待を再度送る",
|
||||||
"Anyone with this link can join this workspace.": "このリンクを知っている人は誰でもワークスペースに参加できます",
|
"Anyone with this link can join this workspace.": "このリンクを持っている人は誰でもこのワークスペースに参加できます。",
|
||||||
"Invite link": "招待リンク",
|
"Invite link": "招待リンク",
|
||||||
"Copy": "コピー",
|
"Copy": "コピー",
|
||||||
"Copy to space": "スペースにコピー",
|
"Copy to space": "スペースにコピー",
|
||||||
@@ -239,13 +239,13 @@
|
|||||||
"Duplicate": "複製",
|
"Duplicate": "複製",
|
||||||
"Select a user": "ユーザを選択",
|
"Select a user": "ユーザを選択",
|
||||||
"Select a group": "グループを選択",
|
"Select a group": "グループを選択",
|
||||||
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
|
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします。",
|
||||||
"Delete space": "スペースを削除",
|
"Delete space": "スペースを削除",
|
||||||
"Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?",
|
"Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?",
|
||||||
"Delete this space with all its pages and data.": "このスペースとすべてのページ、データを削除します",
|
"Delete this space with all its pages and data.": "このスペースおよびスペース内のすべてのページとデータを削除します。",
|
||||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "スペース内のすべてのページ、コメント、添付ファイル、権限が完全に削除されます",
|
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "このスペース内のすべてのページ、コメント、添付ファイル、および権限は完全に削除されます。",
|
||||||
"Confirm space name": "スペース名を確認する",
|
"Confirm space name": "スペース名を確認する",
|
||||||
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "確認のためスペース名 <b>{{spaceName}}</b> を入力してください",
|
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "アクションを確認するためにスペース名 <b>{{spaceName}}</b> を入力してください。",
|
||||||
"Format": "フォーマット",
|
"Format": "フォーマット",
|
||||||
"Include subpages": "サブページを含める",
|
"Include subpages": "サブページを含める",
|
||||||
"Include attachments": "添付ファイルを含める",
|
"Include attachments": "添付ファイルを含める",
|
||||||
@@ -273,12 +273,12 @@
|
|||||||
"Success": "成功",
|
"Success": "成功",
|
||||||
"Warning": "警告",
|
"Warning": "警告",
|
||||||
"Danger": "危険",
|
"Danger": "危険",
|
||||||
"Mermaid diagram error:": "Mermaid ダイアグラムエラー:",
|
"Mermaid diagram error:": "Mermaid コードエラー",
|
||||||
"Invalid Mermaid diagram": "無効な Mermaid ダイアグラムです",
|
"Invalid Mermaid diagram": "無効な Mermaid コードです",
|
||||||
"Double-click to edit Draw.io diagram": "ダブルクリックして Draw.io 図を編集",
|
"Double-click to edit Draw.io diagram": "ダブルクリックしてDraw.ioの図を編集",
|
||||||
"Exit": "終了",
|
"Exit": "終了",
|
||||||
"Save & Exit": "保存して終了",
|
"Save & Exit": "保存して終了",
|
||||||
"Double-click to edit Excalidraw diagram": "ダブルクリックして Excalidraw 図を編集",
|
"Double-click to edit Excalidraw diagram": "ダブルクリックしてExcalidraw図を編集",
|
||||||
"Paste link": "リンクを貼り付け",
|
"Paste link": "リンクを貼り付け",
|
||||||
"Edit link": "リンクを編集",
|
"Edit link": "リンクを編集",
|
||||||
"Remove link": "リンクを削除",
|
"Remove link": "リンクを削除",
|
||||||
@@ -315,22 +315,22 @@
|
|||||||
"Bullet List": "箇条書きリスト",
|
"Bullet List": "箇条書きリスト",
|
||||||
"Numbered List": "番号付きリスト",
|
"Numbered List": "番号付きリスト",
|
||||||
"Blockquote": "引用",
|
"Blockquote": "引用",
|
||||||
"Just start typing with plain text.": "プレーンテキストを入力します",
|
"Just start typing with plain text.": "すぐに文章を書き始められます。",
|
||||||
"Track tasks with a to-do list.": "Todo リストでタスクを管理します",
|
"Track tasks with a to-do list.": "Todoリストでタスクを追跡します。",
|
||||||
"Big section heading.": "大見出し",
|
"Big section heading.": "大きいフォントのセクション見出しです。",
|
||||||
"Medium section heading.": "中見出し",
|
"Medium section heading.": "中くらいのフォントのセクション見出しです。",
|
||||||
"Small section heading.": "小見出し",
|
"Small section heading.": "小さいフォントのセクション見出しです。",
|
||||||
"Create a simple bullet list.": "箇条書きリストを作成します",
|
"Create a simple bullet list.": "シンプルな箇条書きのリストを作成します。",
|
||||||
"Create a list with numbering.": "番号付きリストを作成します",
|
"Create a list with numbering.": "番号付きのリストを作成します。",
|
||||||
"Create block quote.": "引用ブロックを作成します",
|
"Create block quote.": "引用文を作成します。",
|
||||||
"Insert code snippet.": "コードスニペットを挿入します",
|
"Insert code snippet.": "コードスニペットを入力します。",
|
||||||
"Insert horizontal rule divider": "区切り線を挿入します",
|
"Insert horizontal rule divider": "水平線を挿入します。",
|
||||||
"Upload any image from your device.": "デバイスから画像をアップロードします",
|
"Upload any image from your device.": "画像をアップロードします。",
|
||||||
"Upload any video from your device.": "デバイスから動画をアップロードします",
|
"Upload any video from your device.": "動画をアップロードします。",
|
||||||
"Upload any file from your device.": "デバイスからファイルをアップロードします",
|
"Upload any file from your device.": "ファイルをアップロードします。",
|
||||||
"Table": "テーブル",
|
"Table": "テーブル",
|
||||||
"Insert a table.": "テーブルを挿入します",
|
"Insert a table.": "表を挿入します。",
|
||||||
"Insert collapsible block.": "折りたたみブロックを挿入します",
|
"Insert collapsible block.": "折りたたみ可能なブロックを挿入します。",
|
||||||
"Video": "動画",
|
"Video": "動画",
|
||||||
"Divider": "区切り線",
|
"Divider": "区切り線",
|
||||||
"Quote": "引用",
|
"Quote": "引用",
|
||||||
@@ -338,16 +338,16 @@
|
|||||||
"File attachment": "ファイル添付",
|
"File attachment": "ファイル添付",
|
||||||
"Toggle block": "ブロックを切り替える",
|
"Toggle block": "ブロックを切り替える",
|
||||||
"Callout": "コールアウト",
|
"Callout": "コールアウト",
|
||||||
"Insert callout notice.": "コールアウトを挿入します",
|
"Insert callout notice.": "コールアウトブロックを挿入します。",
|
||||||
"Math inline": "インライン数式",
|
"Math inline": "インライン数式",
|
||||||
"Insert inline math equation.": "インライン数式を挿入します",
|
"Insert inline math equation.": "インライン数式を挿入します。",
|
||||||
"Math block": "数式ブロック",
|
"Math block": "数式ブロック",
|
||||||
"Insert math equation": "数式を挿入します",
|
"Insert math equation": "数式を挿入します",
|
||||||
"Mermaid diagram": "Mermaid ダイアグラム",
|
"Mermaid diagram": "Mermaidコード",
|
||||||
"Insert mermaid diagram": "Mermaid ダイアグラムを挿入します",
|
"Insert mermaid diagram": "Mermaidコードを記述して図を挿入します",
|
||||||
"Insert and design Drawio diagrams": "Draw.io 図を挿入・編集します",
|
"Insert and design Drawio diagrams": "Drawioの図を挿入してデザインします",
|
||||||
"Insert current date": "現在の日付を挿入します",
|
"Insert current date": "今日の日付を挿入します",
|
||||||
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
|
"Draw and sketch excalidraw diagrams": "Excalidrawの図を埋め込みます",
|
||||||
"Multiple": "複数",
|
"Multiple": "複数",
|
||||||
"Heading {{level}}": "見出し {{level}}",
|
"Heading {{level}}": "見出し {{level}}",
|
||||||
"Toggle title": "タイトルの表示/非表示を切り替える",
|
"Toggle title": "タイトルの表示/非表示を切り替える",
|
||||||
@@ -357,29 +357,29 @@
|
|||||||
"Yesterday, {{time}}": "昨日、{{time}}",
|
"Yesterday, {{time}}": "昨日、{{time}}",
|
||||||
"Space created successfully": "スペースを作成しました",
|
"Space created successfully": "スペースを作成しました",
|
||||||
"Space updated successfully": "スペースを更新しました",
|
"Space updated successfully": "スペースを更新しました",
|
||||||
"Space deleted successfully": "スペースを削除しました",
|
"Space deleted successfully": "スペースが削除されました",
|
||||||
"Members added successfully": "メンバーを追加しました",
|
"Members added successfully": "メンバーを追加しました",
|
||||||
"Member removed successfully": "メンバーを削除しました",
|
"Member removed successfully": "メンバーが削除されました",
|
||||||
"Member role updated successfully": "メンバーのロールを更新しました",
|
"Member role updated successfully": "メンバーのロールを更新しました",
|
||||||
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
||||||
"Created at: {{time}}": "作成日: {{time}}",
|
"Created at: {{time}}": "が作成しました:{{time}}",
|
||||||
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
||||||
"Word count: {{wordCount}}": "単語数: {{wordCount}}",
|
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
||||||
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
|
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
|
||||||
"New update": "新規更新",
|
"New update": "新規更新",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} が利用可能です",
|
"{{latestVersion}} is available": "{{latestVersion}}は利用可能です",
|
||||||
"Default page edit mode": "デフォルトのページ編集モード",
|
"Default page edit mode": "デフォルトのページ編集モード",
|
||||||
"Choose your preferred page edit mode. Avoid accidental edits.": "お好みのページ編集モードを選択してください(誤編集を防止します)",
|
"Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ぎます。",
|
||||||
"Reading": "読み取り",
|
"Reading": "読み取り",
|
||||||
"Delete member": "メンバーを削除する",
|
"Delete member": "メンバーを削除する",
|
||||||
"Member deleted successfully": "メンバーを削除しました",
|
"Member deleted successfully": "メンバーが削除されました",
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "このメンバーを削除してもよろしいですか?この操作は取り消せません",
|
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
"Move": "移動",
|
"Move": "移動",
|
||||||
"Move page": "ページを移動",
|
"Move page": "ページを移動",
|
||||||
"Move page to a different space.": "ページを別のスペースに移動します",
|
"Move page to a different space.": "ページを別のスペースに移動します。",
|
||||||
"Real-time editor connection lost. Retrying...": "リアルタイム編集の接続が切断されました。再接続中...",
|
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
|
||||||
"Table of contents": "目次",
|
"Table of contents": "目次",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加すると目次が生成されます",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次を生成します。",
|
||||||
"Share": "共有",
|
"Share": "共有",
|
||||||
"Public sharing": "公開共有",
|
"Public sharing": "公開共有",
|
||||||
"Shared by": "共有者",
|
"Shared by": "共有者",
|
||||||
@@ -398,13 +398,13 @@
|
|||||||
"Delete share": "共有を削除",
|
"Delete share": "共有を削除",
|
||||||
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
|
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
|
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
|
||||||
"Share deleted successfully": "共有を削除しました",
|
"Share deleted successfully": "共有が正常に削除されました",
|
||||||
"Share not found": "共有が見つかりません",
|
"Share not found": "共有が見つかりません",
|
||||||
"Failed to share page": "ページの共有に失敗しました",
|
"Failed to share page": "ページの共有に失敗しました",
|
||||||
"Copy page": "ページをコピー",
|
"Copy page": "ページをコピー",
|
||||||
"Copy page to a different space.": "ページを別のスペースにコピーします",
|
"Copy page to a different space.": "ページを別のスペースにコピーします。",
|
||||||
"Page copied successfully": "ページをコピーしました",
|
"Page copied successfully": "ページのコピーに成功しました",
|
||||||
"Page duplicated successfully": "ページを複製しました",
|
"Page duplicated successfully": "ページが正常に複製されました",
|
||||||
"Find": "検索",
|
"Find": "検索",
|
||||||
"Not found": "見つかりません",
|
"Not found": "見つかりません",
|
||||||
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
|
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
|
||||||
@@ -419,26 +419,26 @@
|
|||||||
"Error": "エラー",
|
"Error": "エラー",
|
||||||
"Failed to disable MFA": "MFAの無効化に失敗しました",
|
"Failed to disable MFA": "MFAの無効化に失敗しました",
|
||||||
"Disable two-factor authentication": "二要素認証を無効化",
|
"Disable two-factor authentication": "二要素認証を無効化",
|
||||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効にすると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります",
|
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効化すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります。",
|
||||||
"Please enter your password to disable two-factor authentication:": "二要素認証を無効にするにはパスワードを入力してください",
|
"Please enter your password to disable two-factor authentication:": "二要素認証を無効化するにはパスワードを入力してください:",
|
||||||
"Two-factor authentication has been enabled": "二要素認証を有効にしました",
|
"Two-factor authentication has been enabled": "二要素認証が有効になりました",
|
||||||
"Two-factor authentication has been disabled": "二要素認証を無効にしました",
|
"Two-factor authentication has been disabled": "二要素認証が無効になりました",
|
||||||
"2-step verification": "2段階認証",
|
"2-step verification": "2段階確認",
|
||||||
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証でアカウントを保護します",
|
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証レイヤーでアカウントを保護します。",
|
||||||
"Two-factor authentication is active on your account.": "二要素認証が有効です",
|
"Two-factor authentication is active on your account.": "二要素認証がアカウントで有効です。",
|
||||||
"Add 2FA method": "2FAメソッドを追加",
|
"Add 2FA method": "2FAメソッドを追加",
|
||||||
"Backup codes": "バックアップコード",
|
"Backup codes": "バックアップコード",
|
||||||
"Disable": "無効にする",
|
"Disable": "無効にする",
|
||||||
"Invalid verification code": "無効な認証コード",
|
"Invalid verification code": "無効な認証コード",
|
||||||
"New backup codes have been generated": "新しいバックアップコードを生成しました",
|
"New backup codes have been generated": "新しいバックアップコードが生成されました",
|
||||||
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
|
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
|
||||||
"About backup codes": "バックアップコードについて",
|
"About backup codes": "バックアップコードについて",
|
||||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリにアクセスできない場合、バックアップコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
|
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
|
||||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "新しいバックアップコードはいつでも再生成できます。既存のコードはすべて無効になります",
|
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "いつでも新しいバックアップコードを再生成できます。これにより、既存のすべてのコードが無効になります。",
|
||||||
"Confirm password": "パスワードを確認",
|
"Confirm password": "パスワードを確認",
|
||||||
"Generate new backup codes": "新しいバックアップコードを生成",
|
"Generate new backup codes": "新しいバックアップコードを生成",
|
||||||
"Save your new backup codes": "新しいバックアップコードを保存",
|
"Save your new backup codes": "新しいバックアップコードを保存",
|
||||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効になりました",
|
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効です。",
|
||||||
"Your new backup codes": "新しいバックアップコード",
|
"Your new backup codes": "新しいバックアップコード",
|
||||||
"I've saved my backup codes": "バックアップコードを保存しました",
|
"I've saved my backup codes": "バックアップコードを保存しました",
|
||||||
"Failed to setup MFA": "MFAの設定に失敗しました",
|
"Failed to setup MFA": "MFAの設定に失敗しました",
|
||||||
@@ -449,51 +449,51 @@
|
|||||||
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
|
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
|
||||||
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
|
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
|
||||||
"Verify and enable": "確認と有効化",
|
"Verify and enable": "確認と有効化",
|
||||||
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。もう一度お試しください",
|
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。再試行してください。",
|
||||||
"Backup": "バックアップ",
|
"Backup": "バックアップ",
|
||||||
"Save codes": "コードを保存",
|
"Save codes": "コードを保存",
|
||||||
"Save your backup codes": "バックアップコードを保存",
|
"Save your backup codes": "バックアップコードを保存",
|
||||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリにアクセスできない場合、これらのコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
|
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
|
||||||
"Print": "印刷",
|
"Print": "印刷",
|
||||||
"Two-factor authentication has been set up. Please log in again.": "二要素認証を設定しました。再度ログインしてください",
|
"Two-factor authentication has been set up. Please log in again.": "二要素認証が設定されました。再度ログインしてください。",
|
||||||
"Two-Factor authentication required": "二要素認証が必要です",
|
"Two-Factor authentication required": "二要素認証が必要です",
|
||||||
"Your workspace requires two-factor authentication for all users": "このワークスペースではすべてのユーザーに二要素認証が必要です",
|
"Your workspace requires two-factor authentication for all users": "ワークスペースでは、すべてのユーザーに二要素認証が必要です",
|
||||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースにアクセスするには二要素認証を設定してください。アカウントのセキュリティが強化されます",
|
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースへのアクセスを続けるには、二要素認証を設定する必要があります。これにより、アカウントに追加のセキュリティ層が追加されます。",
|
||||||
"Set up two-factor authentication": "二要素認証を設定",
|
"Set up two-factor authentication": "二要素認証を設定",
|
||||||
"Cancel and logout": "キャンセルしてログアウト",
|
"Cancel and logout": "キャンセルしてログアウト",
|
||||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "このワークスペースでは二要素認証が必要です。続行するには設定してください",
|
"Your workspace requires two-factor authentication. Please set it up to continue.": "ワークスペースでは二要素認証が必要です。続行するには設定してください。",
|
||||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "認証アプリからの確認コードでアカウントのセキュリティが強化されます",
|
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "これにより、認証アプリからの確認コードが必要となり、アカウントに追加のセキュリティ層が追加されます。",
|
||||||
"Password is required": "パスワードが必要です",
|
"Password is required": "パスワードが必要です",
|
||||||
"Password must be at least 8 characters": "パスワードは8文字以上必要です",
|
"Password must be at least 8 characters": "パスワードは8文字以上必要です",
|
||||||
"Please enter a 6-digit code": "6桁のコードを入力してください",
|
"Please enter a 6-digit code": "6桁のコードを入力してください",
|
||||||
"Code must be exactly 6 digits": "コードは6桁で入力してください",
|
"Code must be exactly 6 digits": "コードは正確に6桁である必要があります",
|
||||||
"Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください",
|
"Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください",
|
||||||
"Need help authenticating?": "認証に関するヘルプが必要ですか?",
|
"Need help authenticating?": "認証に関するヘルプが必要ですか?",
|
||||||
"MFA QR Code": "MFA QRコード",
|
"MFA QR Code": "MFA QRコード",
|
||||||
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントを作成しました。二要素認証を設定するためにログインしてください",
|
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントが正常に作成されました。二要素認証を設定するためにログインしてください。",
|
||||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードをリセットしました。新しいパスワードでログインして二要素認証を完了してください",
|
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードのリセットが成功しました。新しいパスワードでログインし、二要素認証を完了してください。",
|
||||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードをリセットしました。新しいパスワードでログインして二要素認証を設定してください",
|
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードのリセットが成功しました。二要素認証を設定するために新しいパスワードでログインしてください。",
|
||||||
"Password reset was successful. Please log in with your new password.": "パスワードをリセットしました。新しいパスワードでログインしてください",
|
"Password reset was successful. Please log in with your new password.": "パスワードのリセットが成功しました。新しいパスワードでログインしてください。",
|
||||||
"Two-factor authentication": "二要素認証",
|
"Two-factor authentication": "二要素認証",
|
||||||
"Use authenticator app instead": "代わりに認証アプリを使用",
|
"Use authenticator app instead": "代わりに認証アプリを使用",
|
||||||
"Verify backup code": "バックアップコードを確認",
|
"Verify backup code": "バックアップコードを確認",
|
||||||
"Use backup code": "バックアップコードを使用",
|
"Use backup code": "バックアップコードを使用",
|
||||||
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
|
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
|
||||||
"Backup code": "バックアップコード",
|
"Backup code": "バックアップコード",
|
||||||
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です",
|
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。",
|
||||||
"Verify": "確認",
|
"Verify": "確認",
|
||||||
"Trash": "ごみ箱",
|
"Trash": "ごみ箱",
|
||||||
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
|
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます。",
|
||||||
"Deleted": "削除",
|
"Deleted": "削除",
|
||||||
"No pages in trash": "ごみ箱にページがありません",
|
"No pages in trash": "ごみ箱にページがありません",
|
||||||
"Permanently delete page?": "ページを完全に削除しますか?",
|
"Permanently delete page?": "ページを完全に削除しますか?",
|
||||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "「{{title}}」を完全に削除しますか?この操作は取り消せません",
|
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}』を完全に削除しますか? この操作は元に戻せません。",
|
||||||
"Restore '{{title}}' and its sub-pages?": "「{{title}}」とそのサブページを復元しますか?",
|
"Restore '{{title}}' and its sub-pages?": "{{title}}』とそのサブページを復元しますか?",
|
||||||
"Move to trash": "ごみ箱に移動",
|
"Move to trash": "ごみ箱に移動",
|
||||||
"Move this page to trash?": "このページをごみ箱に移動しますか?",
|
"Move this page to trash?": "このページをごみ箱に移動しますか?",
|
||||||
"Restore page": "ページを復元",
|
"Restore page": "ページを復元",
|
||||||
"Page moved to trash": "ページをごみ箱に移動しました",
|
"Page moved to trash": "ページがごみ箱に移動されました",
|
||||||
"Page restored successfully": "ページを復元しました",
|
"Page restored successfully": "ページが正常に復元されました",
|
||||||
"Deleted by": "削除者",
|
"Deleted by": "削除者",
|
||||||
"Deleted at": "削除日時",
|
"Deleted at": "削除日時",
|
||||||
"Preview": "プレビュー",
|
"Preview": "プレビュー",
|
||||||
@@ -511,10 +511,10 @@
|
|||||||
"Enterprise": "エンタープライズ",
|
"Enterprise": "エンタープライズ",
|
||||||
"Download attachment": "添付ファイルをダウンロード",
|
"Download attachment": "添付ファイルをダウンロード",
|
||||||
"Allowed email domains": "許可されたメールドメイン",
|
"Allowed email domains": "許可されたメールドメイン",
|
||||||
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインのメールアドレスを持つユーザーのみSSO経由で登録できます",
|
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインからのメールアドレスを持つユーザーのみがSSOで登録できます。",
|
||||||
"Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
|
"Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
|
||||||
"Enforce two-factor authentication": "二要素認証を強制する",
|
"Enforce two-factor authentication": "二要素認証を強制する",
|
||||||
"Once enforced, all members must enable two-factor authentication to access the workspace.": "有効にすると、すべてのメンバーが二要素認証を設定しないとワークスペースにアクセスできなくなります",
|
"Once enforced, all members must enable two-factor authentication to access the workspace.": "一度強制されると、すべてのメンバーはワークスペースにアクセスするために二要素認証を有効にする必要があります。",
|
||||||
"Toggle MFA enforcement": "MFAの強制を切り替える",
|
"Toggle MFA enforcement": "MFAの強制を切り替える",
|
||||||
"Display name": "表示名",
|
"Display name": "表示名",
|
||||||
"Allow signup": "登録を許可する",
|
"Allow signup": "登録を許可する",
|
||||||
@@ -532,10 +532,10 @@
|
|||||||
"Upload image": "画像をアップロード",
|
"Upload image": "画像をアップロード",
|
||||||
"Remove image": "画像を削除",
|
"Remove image": "画像を削除",
|
||||||
"Failed to remove image": "画像の削除に失敗しました",
|
"Failed to remove image": "画像の削除に失敗しました",
|
||||||
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
|
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています。",
|
||||||
"Image removed successfully": "画像を削除しました",
|
"Image removed successfully": "画像が正常に削除されました",
|
||||||
"API key": "APIキー",
|
"API key": "APIキー",
|
||||||
"API key created successfully": "APIキーを作成しました",
|
"API key created successfully": "APIキーが正常に作成されました",
|
||||||
"API keys": "APIキー",
|
"API keys": "APIキー",
|
||||||
"API management": "API管理",
|
"API management": "API管理",
|
||||||
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
|
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
|
||||||
@@ -550,9 +550,9 @@
|
|||||||
"No API keys found": "APIキーが見つかりません",
|
"No API keys found": "APIキーが見つかりません",
|
||||||
"No expiration": "期限なし",
|
"No expiration": "期限なし",
|
||||||
"Revoke API key": "APIキーを無効にする",
|
"Revoke API key": "APIキーを無効にする",
|
||||||
"Revoked successfully": "無効にしました",
|
"Revoked successfully": "正常に無効化されました",
|
||||||
"Select expiration date": "有効期限を選択してください",
|
"Select expiration date": "有効期限を選択してください",
|
||||||
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は取り消せません。このAPIキーを使用しているアプリケーションは動作しなくなります",
|
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は元に戻せません。このAPIキーを使用しているアプリケーションは動作を停止します。",
|
||||||
"Update API key": "APIキーを更新",
|
"Update API key": "APIキーを更新",
|
||||||
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
|
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
|
||||||
"AI settings": "AI設定",
|
"AI settings": "AI設定",
|
||||||
@@ -562,7 +562,7 @@
|
|||||||
"AI is thinking...": "AIが考え中...",
|
"AI is thinking...": "AIが考え中...",
|
||||||
"Ask a question...": "質問を入力...",
|
"Ask a question...": "質問を入力...",
|
||||||
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
|
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
|
||||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用して、ワークスペースコンテンツ全体にわたって意味検索機能を提供します。",
|
||||||
"Toggle AI search": "AI検索を切り替え",
|
"Toggle AI search": "AI検索を切り替え",
|
||||||
"Sources": "ソース",
|
"Sources": "ソース",
|
||||||
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
|
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
|
||||||
|
|||||||
@@ -5,27 +5,26 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Table,
|
Table,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
} from "@mantine/core";
|
} from '@mantine/core';
|
||||||
import { Link } from "react-router-dom";
|
import {Link} from 'react-router-dom';
|
||||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from '@/features/page/page.utils.ts';
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate } from '@/lib/time.ts';
|
||||||
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
|
||||||
import { IconFileDescription } from "@tabler/icons-react";
|
import { IconFileDescription } from '@tabler/icons-react';
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
import { getSpaceUrl } from '@/lib/config.ts';
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecentChanges({ spaceId }: Props) {
|
export default function RecentChanges({spaceId}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
|
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <PageListSkeleton />;
|
return <PageListSkeleton/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
@@ -45,8 +44,8 @@ export default function RecentChanges({ spaceId }: Props) {
|
|||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{page.icon || (
|
{page.icon || (
|
||||||
<ActionIcon variant="transparent" color="gray" size={18}>
|
<ActionIcon variant='transparent' color='gray' size={18}>
|
||||||
<IconFileDescription size={18} />
|
<IconFileDescription size={18}/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -59,23 +58,18 @@ export default function RecentChanges({ spaceId }: Props) {
|
|||||||
{!spaceId && (
|
{!spaceId && (
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge
|
||||||
color={getInitialsColor(page?.space.name)}
|
color="blue"
|
||||||
variant="light"
|
variant="light"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={getSpaceUrl(page?.space.slug)}
|
to={getSpaceUrl(page?.space.slug)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{cursor: 'pointer'}}
|
||||||
>
|
>
|
||||||
{page?.space.name}
|
{page?.space.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
)}
|
)}
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text
|
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
|
||||||
c="dimmed"
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
size="xs"
|
|
||||||
fw={500}
|
|
||||||
>
|
|
||||||
{formattedDate(page.updatedAt)}
|
{formattedDate(page.updatedAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useRef, useState, ReactNode } from "react";
|
|
||||||
import { Text, TextProps, Tooltip } from "@mantine/core";
|
|
||||||
|
|
||||||
type AutoTooltipTextProps = TextProps & {
|
|
||||||
children: ReactNode;
|
|
||||||
tooltipLabel?: string;
|
|
||||||
tooltipProps?: Omit<
|
|
||||||
React.ComponentProps<typeof Tooltip>,
|
|
||||||
"children" | "label"
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AutoTooltipText({
|
|
||||||
children,
|
|
||||||
tooltipLabel,
|
|
||||||
tooltipProps,
|
|
||||||
...textProps
|
|
||||||
}: AutoTooltipTextProps) {
|
|
||||||
const textRef = useRef<HTMLParagraphElement>(null);
|
|
||||||
const [isTruncated, setIsTruncated] = useState(false);
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
const element = textRef.current;
|
|
||||||
if (element) {
|
|
||||||
setIsTruncated(element.scrollWidth > element.clientWidth);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
label={label}
|
|
||||||
disabled={!isTruncated || !label}
|
|
||||||
multiline
|
|
||||||
withArrow
|
|
||||||
{...tooltipProps}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
ref={textRef}
|
|
||||||
truncate
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
{...textProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
|
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
||||||
import { useHover } from "@mantine/hooks";
|
import { useHover } from "@mantine/hooks";
|
||||||
import { formatBytes } from "@/lib";
|
import { formatBytes } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export default function AttachmentView(props: NodeViewProps) {
|
export default function AttachmentView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { node, selected } = props;
|
const { node, selected } = props;
|
||||||
const { url, name, size } = node.attrs;
|
const { url, name, size } = node.attrs;
|
||||||
const { hovered, ref } = useHover();
|
const { hovered, ref } = useHover();
|
||||||
@@ -22,28 +20,26 @@ export default function AttachmentView(props: NodeViewProps) {
|
|||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
h={25}
|
h={25}
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
|
<Group justify="space-between" wrap="nowrap">
|
||||||
{url ? (
|
<IconPaperclip size={20} />
|
||||||
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
|
|
||||||
) : (
|
|
||||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
|
<Text component="span" size="md" truncate="end">
|
||||||
{url ? name : t("Uploading {{name}}", { name })}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
<Text component="span" size="sm" c="dimmed" inline>
|
||||||
{formatBytes(size)}
|
{formatBytes(size)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{url && (selected || hovered) && (
|
{selected || hovered ? (
|
||||||
<a href={getFileUrl(url)} target="_blank">
|
<a href={getFileUrl(url)} target="_blank">
|
||||||
<ActionIcon variant="default" aria-label="download file">
|
<ActionIcon variant="default" aria-label="download file">
|
||||||
<IconDownload size={18} />
|
<IconDownload size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
import {
|
||||||
import { isNodeSelection, useEditorState } from "@tiptap/react";
|
BubbleMenu,
|
||||||
import type { Editor } from "@tiptap/react";
|
BubbleMenuProps,
|
||||||
|
isNodeSelection,
|
||||||
|
useEditor,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import { FC, useEffect, useRef, useState } from "react";
|
import { FC, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
IconBold,
|
IconBold,
|
||||||
@@ -34,7 +38,7 @@ export interface BubbleMenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||||
editor: Editor | null;
|
editor: ReturnType<typeof useEditor>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||||
@@ -129,9 +133,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
}
|
}
|
||||||
return isTextSelected(editor);
|
return isTextSelected(editor);
|
||||||
},
|
},
|
||||||
options: {
|
tippyOptions: {
|
||||||
placement: "top",
|
moveTransition: "transform 0.15s ease-out",
|
||||||
offset: 8,
|
onCreate: (instance) => {
|
||||||
|
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
});
|
||||||
|
},
|
||||||
onHide: () => {
|
onHide: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
@@ -147,7 +156,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
<BubbleMenu {...bubbleMenuProps}>
|
||||||
<div className={classes.bubbleMenu}>
|
<div className={classes.bubbleMenu}>
|
||||||
<NodeSelector
|
<NodeSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
@@ -49,26 +53,17 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
if (!editor) return;
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "callout";
|
const predicate = (node: PMNode) => node.type.name === "callout";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const domRect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const setCalloutType = useCallback(
|
const setCalloutType = useCallback(
|
||||||
@@ -117,12 +112,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`callout-menu`}
|
pluginKey={`callout-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
options={{
|
getReferenceClientRect,
|
||||||
|
offset: [0, 10],
|
||||||
placement: "bottom",
|
placement: "bottom",
|
||||||
// offset: 233, // // offset: [0, 10],
|
zIndex: 99,
|
||||||
// zIndex: 99,
|
popperOptions: {
|
||||||
flip: false,
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
node.textContent.length > 0
|
node.textContent.length > 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* @ts-ignore */}
|
|
||||||
<NodeViewContent as="code" className={`language-${language}`} />
|
<NodeViewContent as="code" className={`language-${language}`} />
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import type { EditorView } from "@tiptap/pm/view";
|
||||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||||
|
import { Slice } from "@tiptap/pm/model";
|
||||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
|
|
||||||
export const handlePaste = (
|
export const handlePaste = (
|
||||||
editor: Editor,
|
view: EditorView,
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
creatorId?: string,
|
creatorId?: string,
|
||||||
@@ -17,7 +18,7 @@ export const handlePaste = (
|
|||||||
// we have to do this validation here to allow the default link extension to takeover if needs be
|
// we have to do this validation here to allow the default link extension to takeover if needs be
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const url = clipboardData.trim();
|
const url = clipboardData.trim();
|
||||||
const { from: pos, empty } = editor.state.selection;
|
const { from: pos, empty } = view.state.selection;
|
||||||
const match = INTERNAL_LINK_REGEX.exec(url);
|
const match = INTERNAL_LINK_REGEX.exec(url);
|
||||||
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
||||||
|
|
||||||
@@ -33,27 +34,19 @@ export const handlePaste = (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
|
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
|
||||||
const urlWithoutAnchor = anchorId
|
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
|
||||||
? url.substring(0, url.indexOf("#"))
|
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
|
||||||
: url;
|
|
||||||
createMentionAction(
|
|
||||||
urlWithoutAnchor,
|
|
||||||
editor.view,
|
|
||||||
pos,
|
|
||||||
creatorId,
|
|
||||||
anchorId,
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.clipboardData?.files.length) {
|
if (event.clipboardData?.files.length) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
for (const file of event.clipboardData.files) {
|
for (const file of event.clipboardData.files) {
|
||||||
const pos = editor.state.selection.from;
|
const pos = view.state.selection.from;
|
||||||
uploadImageAction(file, editor, pos, pageId);
|
uploadImageAction(file, view, pos, pageId);
|
||||||
uploadVideoAction(file, editor, pos, pageId);
|
uploadVideoAction(file, view, pos, pageId);
|
||||||
uploadAttachmentAction(file, editor, pos, pageId);
|
uploadAttachmentAction(file, view, pos, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -61,7 +54,7 @@ export const handlePaste = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleFileDrop = (
|
export const handleFileDrop = (
|
||||||
editor: Editor,
|
view: EditorView,
|
||||||
event: DragEvent,
|
event: DragEvent,
|
||||||
moved: boolean,
|
moved: boolean,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
@@ -70,14 +63,14 @@ export const handleFileDrop = (
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
for (const file of event.dataTransfer.files) {
|
for (const file of event.dataTransfer.files) {
|
||||||
const coordinates = editor.view.posAtCoords({
|
const coordinates = view.posAtCoords({
|
||||||
left: event.clientX,
|
left: event.clientX,
|
||||||
top: event.clientY,
|
top: event.clientY,
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { sticky } from "tippy.js";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -35,26 +40,17 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
if (!editor) return;
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "drawio";
|
const predicate = (node: PMNode) => node.type.name === "drawio";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const domRect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const onWidthChange = useCallback(
|
||||||
@@ -69,11 +65,15 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`drawio-menu`}
|
pluginKey={`drawio-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
options={{
|
getReferenceClientRect,
|
||||||
placement: "top",
|
offset: [0, 8],
|
||||||
offset: 8,
|
zIndex: 99,
|
||||||
flip: false,
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: "popper",
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
const fileName = "diagram.drawio.svg";
|
const fileName = "diagram.drawio.svg";
|
||||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
//@ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
let attachment: IAttachment = null;
|
||||||
|
|||||||
@@ -1,41 +1,16 @@
|
|||||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||||
import EmojiList from "./emoji-list";
|
import EmojiList from "./emoji-list";
|
||||||
|
import tippy from "tippy.js";
|
||||||
import { init } from "emoji-mart";
|
import { init } from "emoji-mart";
|
||||||
import {
|
|
||||||
autoUpdate,
|
|
||||||
computePosition,
|
|
||||||
flip,
|
|
||||||
offset,
|
|
||||||
shift,
|
|
||||||
} from "@floating-ui/dom";
|
|
||||||
|
|
||||||
const renderEmojiItems = () => {
|
const renderEmojiItems = () => {
|
||||||
let component: ReactRenderer | null = null;
|
let component: ReactRenderer | null = null;
|
||||||
let popup: HTMLDivElement | null = null;
|
let popup: any | null = null;
|
||||||
let cleanup: (() => void) | null = null;
|
|
||||||
let getReferenceClientRect: (() => DOMRect) | null = null;
|
|
||||||
|
|
||||||
const destroy = () => {
|
|
||||||
if (cleanup) {
|
|
||||||
cleanup();
|
|
||||||
cleanup = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (popup) {
|
|
||||||
popup.remove();
|
|
||||||
popup = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (component) {
|
|
||||||
component.destroy();
|
|
||||||
component = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onBeforeStart: (props: {
|
onBeforeStart: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: () => DOMRect;
|
clientRect: DOMRect;
|
||||||
}) => {
|
}) => {
|
||||||
init({
|
init({
|
||||||
data: async () => (await import("@emoji-mart/data")).default,
|
data: async () => (await import("@emoji-mart/data")).default,
|
||||||
@@ -50,61 +25,51 @@ const renderEmojiItems = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
getReferenceClientRect = props.clientRect;
|
// @ts-ignore
|
||||||
popup = document.createElement("div");
|
popup = tippy("body", {
|
||||||
popup.style.zIndex = "9999";
|
getReferenceClientRect: props.clientRect,
|
||||||
popup.style.position = "absolute";
|
appendTo: () => document.body,
|
||||||
popup.style.top = "0";
|
content: component.element,
|
||||||
popup.style.left = "0";
|
showOnCreate: true,
|
||||||
popup.appendChild(component.element);
|
interactive: true,
|
||||||
document.body.appendChild(popup);
|
trigger: "manual",
|
||||||
|
placement: "bottom",
|
||||||
const virtualElement = {
|
|
||||||
getBoundingClientRect: () => {
|
|
||||||
return getReferenceClientRect
|
|
||||||
? getReferenceClientRect()
|
|
||||||
: new DOMRect(0, 0, 0, 0);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
cleanup = autoUpdate(virtualElement, popup, () => {
|
|
||||||
if (!popup) return;
|
|
||||||
|
|
||||||
computePosition(virtualElement, popup, {
|
|
||||||
placement: "bottom-start",
|
|
||||||
middleware: [offset(10), flip(), shift()],
|
|
||||||
}).then(({ x, y }) => {
|
|
||||||
if (!popup) return;
|
|
||||||
|
|
||||||
Object.assign(popup.style, {
|
|
||||||
transform: `translate(${x}px, ${y}px)`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: () => DOMRect;
|
clientRect: DOMRect;
|
||||||
}) => {
|
}) => {
|
||||||
component?.updateProps({ ...props, isLoading: false });
|
component?.updateProps({...props, isLoading: false});
|
||||||
|
|
||||||
if (props.clientRect) {
|
if (!props.clientRect) {
|
||||||
getReferenceClientRect = props.clientRect;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
popup &&
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onUpdate: (props: {
|
onUpdate: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: () => DOMRect;
|
clientRect: DOMRect;
|
||||||
}) => {
|
}) => {
|
||||||
component?.updateProps(props);
|
component?.updateProps(props);
|
||||||
|
|
||||||
if (props.clientRect) {
|
if (!props.clientRect) {
|
||||||
getReferenceClientRect = props.clientRect;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
popup &&
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key === "Escape") {
|
||||||
destroy();
|
popup?.[0].hide();
|
||||||
|
component?.destroy()
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -113,7 +78,13 @@ const renderEmojiItems = () => {
|
|||||||
return component?.ref?.onKeyDown(props);
|
return component?.ref?.onKeyDown(props);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
destroy();
|
if (popup && !popup[0]?.state.isDestroyed) {
|
||||||
|
popup[0]?.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
component?.destroy();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { sticky } from "tippy.js";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -37,26 +42,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
if (!editor) return;
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const domRect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const onWidthChange = useCallback(
|
||||||
@@ -69,13 +65,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`excalidraw-menu`}
|
pluginKey={`excalidraw-menu}`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
options={{
|
getReferenceClientRect,
|
||||||
placement: "top",
|
offset: [0, 8],
|
||||||
offset: 8,
|
zIndex: 99,
|
||||||
flip: false,
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: "popper",
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
const fileName = "diagram.excalidraw.svg";
|
const fileName = "diagram.excalidraw.svg";
|
||||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
let attachment: IAttachment = null;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
|
import { sticky } from "tippy.js";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive("image");
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -37,37 +52,17 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldShow = useCallback(
|
const getReferenceClientRect = useCallback(() => {
|
||||||
({ state }: ShouldShowProps) => {
|
|
||||||
if (!state) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return editor.isActive("image") && editor.getAttributes("image").src;
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "image";
|
const predicate = (node: PMNode) => node.type.name === "image";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const domRect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignImageLeft = useCallback(() => {
|
const alignImageLeft = useCallback(() => {
|
||||||
@@ -110,11 +105,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`image-menu`}
|
pluginKey={`image-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
options={{
|
getReferenceClientRect,
|
||||||
placement: "top",
|
offset: [0, 8],
|
||||||
offset: 8,
|
zIndex: 99,
|
||||||
flip: false,
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: "popper",
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
.imageWrapper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: -135% 0%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +1,30 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { Group, Image, Loader, Text } from "@mantine/core";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { Image } from "@mantine/core";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./image-view.module.css";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export default function ImageView(props: NodeViewProps) {
|
export default function ImageView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { node, selected } = props;
|
||||||
const { editor, node, selected } = props;
|
const { src, width, align, title } = node.attrs;
|
||||||
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
|
|
||||||
const alignClass = useMemo(() => {
|
const alignClass = useMemo(() => {
|
||||||
if (align === "left") return "alignLeft";
|
if (align === "left") return "alignLeft";
|
||||||
if (align === "right") return "alignRight";
|
if (align === "right") return "alignRight";
|
||||||
if (align === "center") return "alignCenter";
|
if (align === "center") return "alignCenter";
|
||||||
return "alignCenter";
|
return "alignCenter";
|
||||||
}, [align]);
|
}, [align]);
|
||||||
const previewSrc = useMemo(() => {
|
|
||||||
editor.storage.shared.imagePreviews =
|
|
||||||
editor.storage.shared.imagePreviews || {};
|
|
||||||
|
|
||||||
if (placeholder?.id) {
|
|
||||||
return editor.storage.shared.imagePreviews[placeholder.id];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [placeholder, editor]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle>
|
||||||
<div
|
<Image
|
||||||
className={clsx(
|
radius="md"
|
||||||
selected && "ProseMirror-selectednode",
|
fit="contain"
|
||||||
classes.imageWrapper,
|
w={width}
|
||||||
alignClass,
|
src={getFileUrl(src)}
|
||||||
)}
|
alt={title}
|
||||||
style={{
|
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||||
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
/>
|
||||||
width,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{src && (
|
|
||||||
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
|
|
||||||
)}
|
|
||||||
{!src && previewSrc && (
|
|
||||||
<Group pos="relative" h="100%" w="100%">
|
|
||||||
<Image
|
|
||||||
radius="md"
|
|
||||||
fit="contain"
|
|
||||||
src={previewSrc}
|
|
||||||
alt={placeholder?.name}
|
|
||||||
/>
|
|
||||||
<Loader size={20} pos="absolute" bottom={6} right={6} />
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{!src && !previewSrc && (
|
|
||||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
|
||||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
|
||||||
<Text component="span" size="sm" truncate="end">
|
|
||||||
{placeholder?.name
|
|
||||||
? t("Uploading {{name}}", { name: placeholder.name })
|
|
||||||
: t("Uploading file")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||||
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
||||||
import { Card } from "@mantine/core";
|
import { Card } from "@mantine/core";
|
||||||
import { useEditorState } from "@tiptap/react";
|
|
||||||
|
|
||||||
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||||
const [showEdit, setShowEdit] = useState(false);
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
@@ -60,15 +59,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`link-menu`}
|
pluginKey={`link-menu}`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
options={{
|
tippyOptions={{
|
||||||
onHide: () => {
|
appendTo: () => {
|
||||||
|
return appendTo?.current;
|
||||||
|
},
|
||||||
|
onHidden: () => {
|
||||||
setShowEdit(false);
|
setShowEdit(false);
|
||||||
},
|
},
|
||||||
placement: "bottom",
|
placement: "bottom",
|
||||||
offset: 5,
|
offset: [0, 5],
|
||||||
// zIndex: 101,
|
zIndex: 101,
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
|
|
||||||
setRenderItems(items);
|
setRenderItems(items);
|
||||||
// update editor storage
|
// update editor storage
|
||||||
//@ts-ignore
|
|
||||||
props.editor.storage.mentionItems = items;
|
props.editor.storage.mentionItems = items;
|
||||||
}
|
}
|
||||||
}, [suggestion, isLoading]);
|
}, [suggestion, isLoading]);
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||||
import {
|
import tippy from "tippy.js";
|
||||||
autoUpdate,
|
|
||||||
computePosition,
|
|
||||||
flip,
|
|
||||||
offset,
|
|
||||||
shift,
|
|
||||||
} from "@floating-ui/dom";
|
|
||||||
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
|
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
|
||||||
|
|
||||||
function getWhitespaceCount(query: string) {
|
function getWhitespaceCount(query: string) {
|
||||||
@@ -15,27 +9,16 @@ function getWhitespaceCount(query: string) {
|
|||||||
|
|
||||||
const mentionRenderItems = () => {
|
const mentionRenderItems = () => {
|
||||||
let component: ReactRenderer | null = null;
|
let component: ReactRenderer | null = null;
|
||||||
let activeClientRect: (() => DOMRect) | null = null;
|
let popup: any | null = null;
|
||||||
let updatePositionCleanup: (() => void) | null = null;
|
|
||||||
|
|
||||||
const destroy = () => {
|
|
||||||
updatePositionCleanup?.();
|
|
||||||
updatePositionCleanup = null;
|
|
||||||
component?.destroy();
|
|
||||||
if (component?.element?.parentNode) {
|
|
||||||
component.element.parentNode.removeChild(component.element);
|
|
||||||
}
|
|
||||||
component = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: () => DOMRect;
|
clientRect: DOMRect;
|
||||||
query: string;
|
query: string;
|
||||||
}) => {
|
}) => {
|
||||||
// query must not start with a whitespace
|
// query must not start with a whitespace
|
||||||
if (props.query.charAt(0) === " ") {
|
if (props.query.charAt(0) === ' '){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,88 +37,75 @@ const mentionRenderItems = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeClientRect = props.clientRect;
|
// @ts-ignore
|
||||||
|
popup = tippy("body", {
|
||||||
const { element } = component;
|
getReferenceClientRect: props.clientRect,
|
||||||
document.body.appendChild(element);
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
updatePositionCleanup = autoUpdate(
|
showOnCreate: true,
|
||||||
{
|
interactive: true,
|
||||||
getBoundingClientRect: () =>
|
trigger: "manual",
|
||||||
activeClientRect ? activeClientRect() : new DOMRect(),
|
placement: "bottom-start",
|
||||||
},
|
});
|
||||||
element,
|
|
||||||
() => {
|
|
||||||
if (!component?.element) return;
|
|
||||||
computePosition(
|
|
||||||
{
|
|
||||||
getBoundingClientRect: () => {
|
|
||||||
return activeClientRect ? activeClientRect() : new DOMRect();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
element,
|
|
||||||
{
|
|
||||||
placement: "bottom-start",
|
|
||||||
middleware: [offset(0), flip(), shift()],
|
|
||||||
}
|
|
||||||
).then(({ x, y }) => {
|
|
||||||
Object.assign(element.style, {
|
|
||||||
left: `${x}px`,
|
|
||||||
top: `${y}px`,
|
|
||||||
position: "absolute",
|
|
||||||
zIndex: "9999",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onUpdate: (props: {
|
onUpdate: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: () => DOMRect;
|
clientRect: DOMRect;
|
||||||
query: string;
|
query: string;
|
||||||
}) => {
|
}) => {
|
||||||
// query must not start with a whitespace
|
// query must not start with a whitespace
|
||||||
if (props.query.charAt(0) === " ") {
|
if (props.query.charAt(0) === ' '){
|
||||||
destroy();
|
component?.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// only update component if popup is not destroyed
|
// only update component if popup is not destroyed
|
||||||
if (component) {
|
if (!popup?.[0].state.isDestroyed) {
|
||||||
component.updateProps(props);
|
component?.updateProps(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props || !props.clientRect) {
|
if (!props || !props.clientRect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeClientRect = props.clientRect;
|
|
||||||
|
|
||||||
const whitespaceCount = getWhitespaceCount(props.query);
|
const whitespaceCount = getWhitespaceCount(props.query);
|
||||||
|
|
||||||
// destroy component if space is greater 3 without a match
|
// destroy component if space is greater 3 without a match
|
||||||
if (
|
if (
|
||||||
whitespaceCount > 3 &&
|
whitespaceCount > 3 &&
|
||||||
//@ts-ignore
|
|
||||||
props.editor.storage.mentionItems.length === 0
|
props.editor.storage.mentionItems.length === 0
|
||||||
) {
|
) {
|
||||||
destroy();
|
popup?.[0]?.destroy();
|
||||||
|
component?.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
popup &&
|
||||||
|
!popup?.[0].state.isDestroyed &&
|
||||||
|
popup?.[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key)
|
if (props.event.key)
|
||||||
if (
|
if (
|
||||||
props.event.key === "Escape" ||
|
props.event.key === "Escape" ||
|
||||||
(props.event.key === "Enter" && !component)
|
(props.event.key === "Enter" && !popup?.[0].state.isShown)
|
||||||
) {
|
) {
|
||||||
destroy();
|
popup?.[0].destroy();
|
||||||
|
component?.destroy();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (component?.ref as any)?.onKeyDown(props);
|
return (component?.ref as any)?.onKeyDown(props);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
destroy();
|
if (popup && !popup?.[0].state.isDestroyed) {
|
||||||
|
popup[0].destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
component.destroy();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
||||||
import { IconFileDescription } from "@tabler/icons-react";
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { Link, useLocation, useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import {
|
import {
|
||||||
buildPageUrl,
|
buildPageUrl,
|
||||||
buildSharedPageUrl,
|
buildSharedPageUrl,
|
||||||
} from "@/features/page/page.utils.ts";
|
} from "@/features/page/page.utils.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
|
||||||
import classes from "./mention.module.css";
|
import classes from "./mention.module.css";
|
||||||
|
|
||||||
export default function MentionView(props: NodeViewProps) {
|
export default function MentionView(props: NodeViewProps) {
|
||||||
const { node } = props;
|
const { node } = props;
|
||||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
const navigate = useNavigate();
|
|
||||||
const {
|
const {
|
||||||
data: page,
|
data: page,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -25,20 +23,6 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isShareRoute = location.pathname.startsWith("/share");
|
const isShareRoute = location.pathname.startsWith("/share");
|
||||||
|
|
||||||
const currentPageSlugId = extractPageSlugId(pageSlug);
|
|
||||||
const isSamePage = currentPageSlugId === slugId;
|
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
|
||||||
if (isSamePage && anchorId) {
|
|
||||||
e.preventDefault();
|
|
||||||
const element = document.querySelector(`[id="${anchorId}"]`);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
navigate(`#${anchorId}`, { replace: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const shareSlugUrl = buildSharedPageUrl({
|
const shareSlugUrl = buildSharedPageUrl({
|
||||||
shareId,
|
shareId,
|
||||||
pageSlugId: slugId,
|
pageSlugId: slugId,
|
||||||
@@ -61,7 +45,6 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
to={
|
to={
|
||||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
|
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
|
||||||
}
|
}
|
||||||
onClick={handleClick}
|
|
||||||
underline="never"
|
underline="never"
|
||||||
className={classes.pageMentionLink}
|
className={classes.pageMentionLink}
|
||||||
>
|
>
|
||||||
|
|||||||
-2
@@ -73,8 +73,6 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
|||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||||
//TODO: check type error
|
|
||||||
//@ts-ignore
|
|
||||||
const position: Range = results[resultIndex];
|
const position: Range = results[resultIndex];
|
||||||
|
|
||||||
if (!position) return;
|
if (!position) return;
|
||||||
|
|||||||
@@ -161,7 +161,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
if (!pageId) return;
|
if (!pageId) return;
|
||||||
|
|
||||||
@@ -174,13 +173,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
for (const file of input.files) {
|
for (const file of input.files) {
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
|
uploadImageAction(file, editor.view, pos, pageId);
|
||||||
uploadImageAction(file, editor, pos, pageId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the input value to allow uploading the same file again if needed
|
|
||||||
input.value = "";
|
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -193,7 +188,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
if (!pageId) return;
|
if (!pageId) return;
|
||||||
|
|
||||||
@@ -201,18 +195,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "video/*";
|
input.accept = "video/*";
|
||||||
input.multiple = true;
|
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
for (const file of input.files) {
|
const file = input.files[0];
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
|
uploadVideoAction(file, editor.view, pos, pageId);
|
||||||
uploadVideoAction(file, editor, pos, pageId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the input value to allow uploading the same file again if needed
|
|
||||||
input.value = "";
|
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -225,7 +213,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
if (!pageId) return;
|
if (!pageId) return;
|
||||||
|
|
||||||
@@ -233,18 +220,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "";
|
input.accept = "";
|
||||||
input.multiple = true;
|
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
for (const file of input.files) {
|
const file = input.files[0];
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
|
uploadAttachmentAction(file, editor.view, pos, pageId, true);
|
||||||
uploadAttachmentAction(file, editor, pos, pageId, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the input value to allow uploading the same file again if needed
|
|
||||||
input.value = "";
|
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,35 +1,10 @@
|
|||||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||||
import CommandList from "@/features/editor/components/slash-menu/command-list";
|
import CommandList from "@/features/editor/components/slash-menu/command-list";
|
||||||
import {
|
import tippy from "tippy.js";
|
||||||
autoUpdate,
|
|
||||||
computePosition,
|
|
||||||
flip,
|
|
||||||
offset,
|
|
||||||
shift,
|
|
||||||
} from "@floating-ui/dom";
|
|
||||||
|
|
||||||
const renderItems = () => {
|
const renderItems = () => {
|
||||||
let component: ReactRenderer | null = null;
|
let component: ReactRenderer | null = null;
|
||||||
let popup: HTMLElement | null = null;
|
let popup: any | null = null;
|
||||||
let cleanup: (() => void) | null = null;
|
|
||||||
let getReferenceClientRect: (() => DOMRect) | null = null;
|
|
||||||
|
|
||||||
const updatePosition = () => {
|
|
||||||
if (!popup || !getReferenceClientRect) return;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const rect = getReferenceClientRect();
|
|
||||||
|
|
||||||
computePosition({ getBoundingClientRect: () => rect }, popup, {
|
|
||||||
placement: "bottom-start",
|
|
||||||
middleware: [offset(0), flip(), shift()],
|
|
||||||
}).then(({ x, y }) => {
|
|
||||||
if (popup) {
|
|
||||||
popup.style.left = `${x}px`;
|
|
||||||
popup.style.top = `${y}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
@@ -46,29 +21,15 @@ const renderItems = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
getReferenceClientRect = props.clientRect;
|
popup = tippy("body", {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
popup = document.createElement("div");
|
appendTo: () => document.body,
|
||||||
popup.style.zIndex = "9999";
|
content: component.element,
|
||||||
popup.style.position = "absolute";
|
showOnCreate: true,
|
||||||
popup.style.top = "0";
|
interactive: true,
|
||||||
popup.style.left = "0";
|
trigger: "manual",
|
||||||
|
placement: "bottom-start",
|
||||||
document.body.appendChild(popup);
|
});
|
||||||
popup.appendChild(component.element);
|
|
||||||
|
|
||||||
cleanup = autoUpdate(
|
|
||||||
// @ts-ignore
|
|
||||||
{
|
|
||||||
getBoundingClientRect: () => {
|
|
||||||
return getReferenceClientRect
|
|
||||||
? getReferenceClientRect()
|
|
||||||
: new DOMRect();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
popup,
|
|
||||||
updatePosition
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onUpdate: (props: {
|
onUpdate: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
@@ -80,15 +41,14 @@ const renderItems = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
popup &&
|
||||||
getReferenceClientRect = props.clientRect;
|
popup[0].setProps({
|
||||||
updatePosition();
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key === "Escape") {
|
||||||
if (popup) {
|
popup?.[0].hide();
|
||||||
popup.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -97,19 +57,12 @@ const renderItems = () => {
|
|||||||
return component?.ref?.onKeyDown(props);
|
return component?.ref?.onKeyDown(props);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
if (cleanup) {
|
if (popup && !popup[0].state.isDestroyed) {
|
||||||
cleanup();
|
popup[0].destroy();
|
||||||
cleanup = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (popup) {
|
|
||||||
popup.remove();
|
|
||||||
popup = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (component) {
|
if (component) {
|
||||||
component.destroy();
|
component.destroy();
|
||||||
component = null;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
posToDOMRect,
|
||||||
|
findParentNode,
|
||||||
|
} from "@tiptap/react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import React, { JSX, useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { sticky } from "tippy.js";
|
||||||
|
|
||||||
interface SubpagesMenuProps {
|
interface SubpagesMenuProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -29,7 +33,7 @@ export const SubpagesMenu = React.memo(
|
|||||||
|
|
||||||
return editor.isActive("subpages");
|
return editor.isActive("subpages");
|
||||||
},
|
},
|
||||||
[editor]
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getReferenceClientRect = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
@@ -58,8 +62,18 @@ export const SubpagesMenu = React.memo(
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`subpages-menu`}
|
pluginKey={`subpages-menu}`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
|
tippyOptions={{
|
||||||
|
getReferenceClientRect,
|
||||||
|
offset: [0, 8],
|
||||||
|
zIndex: 99,
|
||||||
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: "popper",
|
||||||
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
@@ -75,7 +89,7 @@ export const SubpagesMenu = React.memo(
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default SubpagesMenu;
|
export default SubpagesMenu;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export default function SubpagesView(props: NodeViewProps) {
|
|||||||
const { spaceSlug, shareId } = useParams();
|
const { spaceSlug, shareId } = useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
//@ts-ignore
|
|
||||||
const currentPageId = editor.storage.pageId;
|
const currentPageId = editor.storage.pageId;
|
||||||
|
|
||||||
// Get subpages from shared tree if we're in a shared context
|
// Get subpages from shared tree if we're in a shared context
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { JSX, useCallback } from "react";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -15,7 +17,6 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TableBackgroundColor } from "./table-background-color";
|
import { TableBackgroundColor } from "./table-background-color";
|
||||||
import { TableTextAlignment } from "./table-text-alignment";
|
import { TableTextAlignment } from "./table-text-alignment";
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
|
||||||
|
|
||||||
export const TableCellMenu = React.memo(
|
export const TableCellMenu = React.memo(
|
||||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||||
@@ -28,7 +29,7 @@ export const TableCellMenu = React.memo(
|
|||||||
|
|
||||||
return isCellSelection(state.selection);
|
return isCellSelection(state.selection);
|
||||||
},
|
},
|
||||||
[editor]
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const mergeCells = useCallback(() => {
|
const mergeCells = useCallback(() => {
|
||||||
@@ -52,27 +53,23 @@ export const TableCellMenu = React.memo(
|
|||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey="table-cell-menu"
|
pluginKey="table-cell-menu"
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
appendTo={() => {
|
tippyOptions={{
|
||||||
return appendTo?.current;
|
appendTo: () => {
|
||||||
}}
|
return appendTo?.current;
|
||||||
ref={(element) => {
|
|
||||||
element.style.zIndex = "99";
|
|
||||||
}}
|
|
||||||
options={{
|
|
||||||
offset: {
|
|
||||||
mainAxis: 15,
|
|
||||||
},
|
},
|
||||||
|
offset: [0, 15],
|
||||||
|
zIndex: 99,
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<TableBackgroundColor editor={editor} />
|
<TableBackgroundColor editor={editor} />
|
||||||
<TableTextAlignment editor={editor} />
|
<TableTextAlignment editor={editor} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Merge cells")}>
|
<Tooltip position="top" label={t("Merge cells")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={mergeCells}
|
onClick={mergeCells}
|
||||||
@@ -128,9 +125,9 @@ export const TableCellMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
</BubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default TableCellMenu;
|
export default TableCellMenu;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
import {
|
||||||
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
posToDOMRect,
|
||||||
|
findParentNode,
|
||||||
|
} from "@tiptap/react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import React, { JSX, useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -12,12 +17,9 @@ import {
|
|||||||
IconColumnRemove,
|
IconColumnRemove,
|
||||||
IconRowInsertBottom,
|
IconRowInsertBottom,
|
||||||
IconRowInsertTop,
|
IconRowInsertTop,
|
||||||
IconRowRemove,
|
IconRowRemove, IconTableColumn, IconTableRow,
|
||||||
IconTableColumn,
|
|
||||||
IconTableRow,
|
|
||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from "@tabler/icons-react";
|
} from '@tabler/icons-react';
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -32,28 +34,20 @@ export const TableMenu = React.memo(
|
|||||||
|
|
||||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||||
},
|
},
|
||||||
[editor]
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "table";
|
const predicate = (node: PMNode) => node.type.name === "table";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const rect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => rect,
|
|
||||||
getClientRects: () => [rect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => rect,
|
|
||||||
getClientRects: () => [rect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const toggleHeaderColumn = useCallback(() => {
|
const toggleHeaderColumn = useCallback(() => {
|
||||||
@@ -93,33 +87,42 @@ export const TableMenu = React.memo(
|
|||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey="table-menu"
|
pluginKey="table-menu"
|
||||||
resizeDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
ref={(element) => {
|
getReferenceClientRect: getReferenceClientRect,
|
||||||
element.style.zIndex = "99";
|
offset: [0, 15],
|
||||||
}}
|
zIndex: 99,
|
||||||
options={{
|
popperOptions: {
|
||||||
placement: "top",
|
modifiers: [
|
||||||
offset: {
|
{
|
||||||
mainAxis: 15,
|
name: "preventOverflow",
|
||||||
},
|
enabled: true,
|
||||||
flip: {
|
options: {
|
||||||
fallbackPlacements: ["top", "bottom"],
|
altAxis: true,
|
||||||
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
|
boundary: "clippingParents",
|
||||||
boundary: editor.options.element as HTMLElement,
|
padding: 8,
|
||||||
},
|
},
|
||||||
shift: {
|
},
|
||||||
padding: 8 + 15,
|
{
|
||||||
crossAxis: true,
|
name: "flip",
|
||||||
|
enabled: true,
|
||||||
|
options: {
|
||||||
|
boundary: editor.options.element,
|
||||||
|
fallbackPlacements: ["top", "bottom"],
|
||||||
|
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<Tooltip position="top" label={t("Add left column")}>
|
<Tooltip position="top" label={t("Add left column")}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addColumnLeft}
|
onClick={addColumnLeft}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -185,7 +188,8 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header row")}>
|
<Tooltip position="top" label={t("Toggle header row")}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderRow}
|
onClick={toggleHeaderRow}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -196,7 +200,8 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header column")}>
|
<Tooltip position="top" label={t("Toggle header column")}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderColumn}
|
onClick={toggleHeaderColumn}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -219,9 +224,9 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
</BubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default TableMenu;
|
export default TableMenu;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
|
import { sticky } from "tippy.js";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive("video");
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -37,37 +52,17 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldShow = useCallback(
|
const getReferenceClientRect = useCallback(() => {
|
||||||
({ state }: ShouldShowProps) => {
|
|
||||||
if (!state) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return editor.isActive("video") && editor.getAttributes("video").src;
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "video";
|
const predicate = (node: PMNode) => node.type.name === "video";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const domRect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignVideoLeft = useCallback(() => {
|
const alignVideoLeft = useCallback(() => {
|
||||||
@@ -110,11 +105,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`video-menu`}
|
pluginKey={`video-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
options={{
|
getReferenceClientRect,
|
||||||
placement: "top",
|
offset: [0, 8],
|
||||||
offset: 8,
|
zIndex: 99,
|
||||||
flip: false,
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: "popper",
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
.videoWrapper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: -135% 0%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.video {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
@@ -1,75 +1,29 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { Group, Loader, Text } from "@mantine/core";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./video-view.module.css";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export default function VideoView(props: NodeViewProps) {
|
export default function VideoView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { node, selected } = props;
|
||||||
const { editor, node, selected } = props;
|
const { src, width, align } = node.attrs;
|
||||||
const { src, width, align, aspectRatio, placeholder } = node.attrs;
|
|
||||||
const alignClass = useMemo(() => {
|
const alignClass = useMemo(() => {
|
||||||
if (align === "left") return "alignLeft";
|
if (align === "left") return "alignLeft";
|
||||||
if (align === "right") return "alignRight";
|
if (align === "right") return "alignRight";
|
||||||
if (align === "center") return "alignCenter";
|
if (align === "center") return "alignCenter";
|
||||||
return "alignCenter";
|
return "alignCenter";
|
||||||
}, [align]);
|
}, [align]);
|
||||||
const previewSrc = useMemo(() => {
|
|
||||||
editor.storage.shared.videoPreviews =
|
|
||||||
editor.storage.shared.videoPreviews || {};
|
|
||||||
|
|
||||||
if (placeholder?.id) {
|
|
||||||
return editor.storage.shared.videoPreviews[placeholder.id];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [placeholder, editor]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle>
|
||||||
<div
|
<video
|
||||||
className={clsx(
|
preload="metadata"
|
||||||
selected && "ProseMirror-selectednode",
|
width={width}
|
||||||
classes.videoWrapper,
|
controls
|
||||||
alignClass,
|
src={getFileUrl(src)}
|
||||||
)}
|
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||||
style={{
|
style={{ display: "block" }}
|
||||||
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
/>
|
||||||
width,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{src && (
|
|
||||||
<video
|
|
||||||
className={classes.video}
|
|
||||||
preload="metadata"
|
|
||||||
controls
|
|
||||||
src={getFileUrl(src)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!src && previewSrc && (
|
|
||||||
<Group pos="relative" h="100%" w="100%">
|
|
||||||
<video
|
|
||||||
className={classes.video}
|
|
||||||
preload="metadata"
|
|
||||||
controls
|
|
||||||
src={previewSrc}
|
|
||||||
/>
|
|
||||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{!src && !previewSrc && (
|
|
||||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
|
||||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
|
||||||
<Text component="span" size="sm" truncate="end">
|
|
||||||
{placeholder?.name
|
|
||||||
? t("Uploading {{name}}", { name: placeholder.name })
|
|
||||||
: t("Uploading file")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { TextAlign } from "@tiptap/extension-text-align";
|
import { TextAlign } from "@tiptap/extension-text-align";
|
||||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||||
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
import { TaskList } from "@tiptap/extension-task-list";
|
||||||
|
import { ListKeymap } from "@tiptap/extension-list-keymap";
|
||||||
|
import { TaskItem } from "@tiptap/extension-task-item";
|
||||||
|
import { Underline } from "@tiptap/extension-underline";
|
||||||
import { Superscript } from "@tiptap/extension-superscript";
|
import { Superscript } from "@tiptap/extension-superscript";
|
||||||
import SubScript from "@tiptap/extension-subscript";
|
import SubScript from "@tiptap/extension-subscript";
|
||||||
import { Typography } from "@tiptap/extension-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
@@ -11,7 +15,7 @@ import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
|||||||
import { Youtube } from "@tiptap/extension-youtube";
|
import { Youtube } from "@tiptap/extension-youtube";
|
||||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||||
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||||
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
|
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import {
|
import {
|
||||||
Comment,
|
Comment,
|
||||||
@@ -37,12 +41,11 @@ import {
|
|||||||
Embed,
|
Embed,
|
||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
Mention,
|
Mention,
|
||||||
TableDndExtension,
|
|
||||||
Subpages,
|
Subpages,
|
||||||
|
TableDndExtension,
|
||||||
Heading,
|
Heading,
|
||||||
Highlight,
|
Highlight,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
SharedStorage,
|
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -94,9 +97,7 @@ lowlight.register("scala", scala);
|
|||||||
export const mainExtensions = [
|
export const mainExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: false,
|
heading: false,
|
||||||
undoRedo: false,
|
history: false,
|
||||||
link: false,
|
|
||||||
trailingNode: false,
|
|
||||||
dropcursor: {
|
dropcursor: {
|
||||||
width: 3,
|
width: 3,
|
||||||
color: "#70CFF8",
|
color: "#70CFF8",
|
||||||
@@ -108,7 +109,6 @@ export const mainExtensions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
SharedStorage,
|
|
||||||
Heading,
|
Heading,
|
||||||
UniqueID.configure({
|
UniqueID.configure({
|
||||||
types: ["heading", "paragraph"],
|
types: ["heading", "paragraph"],
|
||||||
@@ -134,6 +134,8 @@ export const mainExtensions = [
|
|||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
|
ListKeymap,
|
||||||
|
Underline,
|
||||||
LinkExtension.configure({
|
LinkExtension.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
}),
|
}),
|
||||||
@@ -168,9 +170,6 @@ export const mainExtensions = [
|
|||||||
},
|
},
|
||||||
}).extend({
|
}).extend({
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(MentionView);
|
return ReactNodeViewRenderer(MentionView);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -209,7 +208,6 @@ export const mainExtensions = [
|
|||||||
}),
|
}),
|
||||||
CustomCodeBlock.configure({
|
CustomCodeBlock.configure({
|
||||||
view: CodeBlockView,
|
view: CodeBlockView,
|
||||||
//@ts-ignore
|
|
||||||
lowlight,
|
lowlight,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
spellcheck: false,
|
spellcheck: false,
|
||||||
@@ -260,9 +258,8 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
|||||||
export const collabExtensions: CollabExtensions = (provider, user) => [
|
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||||
Collaboration.configure({
|
Collaboration.configure({
|
||||||
document: provider.document,
|
document: provider.document,
|
||||||
provider,
|
|
||||||
}),
|
}),
|
||||||
CollaborationCaret.configure({
|
CollaborationCursor.configure({
|
||||||
provider,
|
provider,
|
||||||
user: {
|
user: {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, {
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import {
|
import {
|
||||||
HocuspocusProvider,
|
HocuspocusProvider,
|
||||||
onStatusParameters,
|
onAuthenticationFailedParameters,
|
||||||
WebSocketStatus,
|
WebSocketStatus,
|
||||||
HocuspocusProviderWebsocket,
|
|
||||||
onSyncedParameters,
|
|
||||||
} from "@hocuspocus/provider";
|
} from "@hocuspocus/provider";
|
||||||
import {
|
import {
|
||||||
Editor,
|
|
||||||
EditorContent,
|
EditorContent,
|
||||||
EditorProvider,
|
EditorProvider,
|
||||||
useEditor,
|
useEditor,
|
||||||
@@ -78,140 +69,161 @@ export default function PageEditor({
|
|||||||
editable,
|
editable,
|
||||||
content,
|
content,
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
|
|
||||||
|
|
||||||
const collaborationURL = useCollaborationUrl();
|
const collaborationURL = useCollaborationUrl();
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
const editorRef = useRef<Editor | null>(null);
|
const editorCreated = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isComponentMounted.current = true;
|
isComponentMounted.current = true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setEditor] = useAtom(pageEditorAtom);
|
const [, setEditor] = useAtom(pageEditorAtom);
|
||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
const ydocRef = useRef<Y.Doc | null>(null);
|
||||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
if (!ydocRef.current) {
|
||||||
|
ydocRef.current = new Y.Doc();
|
||||||
|
}
|
||||||
|
const ydoc = ydocRef.current;
|
||||||
|
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||||
|
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
|
const documentName = `page.${pageId}`;
|
||||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||||
const documentState = useDocumentVisibility();
|
const documentState = useDocumentVisibility();
|
||||||
|
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const userPageEditMode =
|
const userPageEditMode =
|
||||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
const canScroll = useCallback(
|
|
||||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
|
||||||
[isComponentMounted],
|
|
||||||
);
|
|
||||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||||
// Providers only created once per pageId
|
// Providers only created once per pageId
|
||||||
const providersRef = useRef<{
|
const providersRef = useRef<{
|
||||||
local: IndexeddbPersistence;
|
local: IndexeddbPersistence;
|
||||||
remote: HocuspocusProvider;
|
remote: HocuspocusProvider;
|
||||||
socket: HocuspocusProviderWebsocket;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [providersReady, setProvidersReady] = useState(false);
|
const [providersReady, setProvidersReady] = useState(false);
|
||||||
|
|
||||||
|
const localProvider = providersRef.current?.local;
|
||||||
|
const remoteProvider = providersRef.current?.remote;
|
||||||
|
|
||||||
|
// Track when collaborative provider is ready and synced
|
||||||
|
const [collabReady, setCollabReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
remoteProvider?.status === WebSocketStatus.Connected &&
|
||||||
|
isLocalSynced &&
|
||||||
|
isRemoteSynced
|
||||||
|
) {
|
||||||
|
setCollabReady(true);
|
||||||
|
}
|
||||||
|
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!providersRef.current) {
|
if (!providersRef.current) {
|
||||||
const documentName = `page.${pageId}`;
|
|
||||||
const ydoc = new Y.Doc();
|
|
||||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||||
const socket = new HocuspocusProviderWebsocket({
|
local.on("synced", () => setLocalSynced(true));
|
||||||
url: collaborationURL,
|
|
||||||
});
|
|
||||||
const onLocalSyncedHandler = () => {
|
|
||||||
setIsLocalSynced(true);
|
|
||||||
};
|
|
||||||
const onStatusHandler = (event: onStatusParameters) => {
|
|
||||||
setYjsConnectionStatus(event.status);
|
|
||||||
};
|
|
||||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
|
||||||
setIsRemoteSynced(event.state);
|
|
||||||
};
|
|
||||||
const onAuthenticationFailedHandler = () => {
|
|
||||||
const payload = jwtDecode(collabQuery?.token);
|
|
||||||
const now = Date.now().valueOf() / 1000;
|
|
||||||
const isTokenExpired = now >= payload.exp;
|
|
||||||
if (isTokenExpired) {
|
|
||||||
refetchCollabToken().then((result) => {
|
|
||||||
if (result.data?.token) {
|
|
||||||
socket.disconnect();
|
|
||||||
setTimeout(() => {
|
|
||||||
remote.configuration.token = result.data.token;
|
|
||||||
socket.connect();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const remote = new HocuspocusProvider({
|
const remote = new HocuspocusProvider({
|
||||||
websocketProvider: socket,
|
|
||||||
name: documentName,
|
name: documentName,
|
||||||
|
url: collaborationURL,
|
||||||
document: ydoc,
|
document: ydoc,
|
||||||
token: collabQuery?.token,
|
token: collabQuery?.token,
|
||||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
connect: true,
|
||||||
onStatus: onStatusHandler,
|
preserveConnection: false,
|
||||||
onSynced: onSyncedHandler,
|
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||||
|
const payload = jwtDecode(collabQuery?.token);
|
||||||
|
const now = Date.now().valueOf() / 1000;
|
||||||
|
const isTokenExpired = now >= payload.exp;
|
||||||
|
if (isTokenExpired) {
|
||||||
|
refetchCollabToken().then((result) => {
|
||||||
|
if (result.data?.token) {
|
||||||
|
remote.disconnect();
|
||||||
|
setTimeout(() => {
|
||||||
|
remote.configuration.token = result.data.token;
|
||||||
|
remote.connect();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStatus: (status) => {
|
||||||
|
if (status.status === "connected") {
|
||||||
|
setYjsConnectionStatus(status.status);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
remote.on("synced", () => setRemoteSynced(true));
|
||||||
local.on("synced", onLocalSyncedHandler);
|
remote.on("disconnect", () => {
|
||||||
providersRef.current = { socket, local, remote };
|
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||||
|
});
|
||||||
|
providersRef.current = { local, remote };
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
} else {
|
} else {
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
}
|
}
|
||||||
// Only destroy on final unmount
|
// Only destroy on final unmount
|
||||||
return () => {
|
return () => {
|
||||||
providersRef.current?.socket.destroy();
|
|
||||||
providersRef.current?.remote.destroy();
|
providersRef.current?.remote.destroy();
|
||||||
providersRef.current?.local.destroy();
|
providersRef.current?.local.destroy();
|
||||||
providersRef.current = null;
|
providersRef.current = null;
|
||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
useEffect(() => {
|
||||||
|
// Handle token updates by reconnecting with new token
|
||||||
|
if (providersRef.current?.remote && collabQuery?.token) {
|
||||||
|
const currentToken = providersRef.current.remote.configuration.token;
|
||||||
|
if (currentToken !== collabQuery.token) {
|
||||||
|
// Token has changed, need to reconnect with new token
|
||||||
|
providersRef.current.remote.disconnect();
|
||||||
|
providersRef.current.remote.configuration.token = collabQuery.token;
|
||||||
|
providersRef.current.remote.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [collabQuery?.token]);
|
||||||
|
*/
|
||||||
|
|
||||||
// Only connect/disconnect on tab/idle, not destroy
|
// Only connect/disconnect on tab/idle, not destroy
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!providersReady || !providersRef.current) return;
|
if (!providersReady || !providersRef.current) return;
|
||||||
const socket = providersRef.current.socket;
|
const remoteProvider = providersRef.current.remote;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isIdle &&
|
isIdle &&
|
||||||
documentState === "hidden" &&
|
documentState === "hidden" &&
|
||||||
yjsConnectionStatus === WebSocketStatus.Connected
|
remoteProvider.status === WebSocketStatus.Connected
|
||||||
) {
|
) {
|
||||||
socket.disconnect();
|
remoteProvider.disconnect();
|
||||||
|
setIsCollabReady(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
documentState === "visible" &&
|
documentState === "visible" &&
|
||||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
remoteProvider.status === WebSocketStatus.Disconnected
|
||||||
) {
|
) {
|
||||||
resetIdle();
|
resetIdle();
|
||||||
socket.connect();
|
remoteProvider.connect();
|
||||||
|
setTimeout(() => setIsCollabReady(true), 500);
|
||||||
}
|
}
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||||
|
|
||||||
// Attach here, to make sure the connection gets properly established
|
|
||||||
providersRef.current?.remote.attach();
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
||||||
return mainExtensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteProvider = providersRef.current.remote;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...mainExtensions,
|
...mainExtensions,
|
||||||
...collabExtensions(remoteProvider, currentUser?.user),
|
...collabExtensions(remoteProvider, currentUser?.user),
|
||||||
];
|
];
|
||||||
}, [providersReady, currentUser?.user]);
|
}, [remoteProvider, currentUser?.user]);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@@ -254,30 +266,18 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handlePaste: (_view, event) => {
|
handlePaste: (view, event, slice) =>
|
||||||
if (!editorRef.current) return false;
|
handlePaste(view, event, pageId, currentUser?.user.id),
|
||||||
|
handleDrop: (view, event, _slice, moved) =>
|
||||||
return handlePaste(
|
handleFileDrop(view, event, moved, pageId),
|
||||||
editorRef.current,
|
|
||||||
event,
|
|
||||||
pageId,
|
|
||||||
currentUser?.user.id,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
handleDrop: (_view, event, _slice, moved) => {
|
|
||||||
if (!editorRef.current) return false;
|
|
||||||
|
|
||||||
return handleFileDrop(editorRef.current, event, moved, pageId);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
onCreate({ editor }) {
|
onCreate({ editor }) {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setEditor(editor);
|
setEditor(editor);
|
||||||
// @ts-ignore
|
|
||||||
editor.storage.pageId = pageId;
|
editor.storage.pageId = pageId;
|
||||||
handleScrollTo(editor);
|
handleScrollTo(editor);
|
||||||
editorRef.current = editor;
|
editorCreated.current = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
@@ -287,7 +287,7 @@ export default function PageEditor({
|
|||||||
debouncedUpdateContent(editorJson);
|
debouncedUpdateContent(editorJson);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, extensions],
|
[pageId, editable, remoteProvider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editorIsEditable = useEditorState({
|
const editorIsEditable = useEditorState({
|
||||||
@@ -343,17 +343,30 @@ export default function PageEditor({
|
|||||||
setAsideState({ tab: "", isAsideOpen: false });
|
setAsideState({ tab: "", isAsideOpen: false });
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (remoteProvider?.status === WebSocketStatus.Connecting) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, [remoteProvider?.status]);
|
||||||
|
|
||||||
const isSynced = isLocalSynced && isRemoteSynced;
|
const isSynced = isLocalSynced && isRemoteSynced;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const collabReadyTimeout = setTimeout(() => {
|
||||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
if (
|
||||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
!isCollabReady &&
|
||||||
|
isSynced &&
|
||||||
|
remoteProvider?.status === WebSocketStatus.Connected
|
||||||
|
) {
|
||||||
|
setIsCollabReady(true);
|
||||||
}
|
}
|
||||||
}, 7500);
|
}, 500);
|
||||||
|
return () => clearTimeout(collabReadyTimeout);
|
||||||
|
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||||
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [yjsConnectionStatus, isSynced]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only honor user default page edit mode preference and permissions
|
// Only honor user default page edit mode preference and permissions
|
||||||
if (editor) {
|
if (editor) {
|
||||||
@@ -375,13 +388,12 @@ export default function PageEditor({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!hasConnectedOnceRef.current &&
|
!hasConnectedOnceRef.current &&
|
||||||
yjsConnectionStatus === WebSocketStatus.Connected &&
|
remoteProvider?.status === WebSocketStatus.Connected
|
||||||
isSynced
|
|
||||||
) {
|
) {
|
||||||
hasConnectedOnceRef.current = true;
|
hasConnectedOnceRef.current = true;
|
||||||
setShowStatic(false);
|
setShowStatic(false);
|
||||||
}
|
}
|
||||||
}, [yjsConnectionStatus, isSynced]);
|
}, [remoteProvider?.status]);
|
||||||
|
|
||||||
if (showStatic) {
|
if (showStatic) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ export default function ReadonlyPageEditor({
|
|||||||
onCreate={({ editor }) => {
|
onCreate={({ editor }) => {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
if (pageId) {
|
if (pageId) {
|
||||||
// @ts-ignore
|
|
||||||
editor.storage.pageId = pageId;
|
editor.storage.pageId = pageId;
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Give a remote user a caret */
|
/* Give a remote user a caret */
|
||||||
.collaboration-carets__caret {
|
.collaboration-cursor__caret {
|
||||||
border-left: 1px solid #0d0d0d;
|
border-left: 1px solid #0d0d0d;
|
||||||
border-right: 1px solid #0d0d0d;
|
border-right: 1px solid #0d0d0d;
|
||||||
margin-left: -1px;
|
margin-left: -1px;
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Render the username above the caret */
|
/* Render the username above the caret */
|
||||||
.collaboration-carets__label {
|
.collaboration-cursor__label {
|
||||||
border-radius: 3px 3px 3px 0;
|
border-radius: 3px 3px 3px 0;
|
||||||
color: #0d0d0d;
|
color: #0d0d0d;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
|
import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
|
||||||
import { IApiKey } from "@/ee/api-key";
|
|
||||||
|
|
||||||
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
|
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
|
||||||
const req = await api.post<IFileTask>("/file-tasks/info", {
|
const req = await api.post<IFileTask>("/file-tasks/info", {
|
||||||
@@ -10,10 +8,7 @@ export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFileTasks(
|
export async function getFileTasks(): Promise<IFileTask[]> {
|
||||||
params?: QueryParams,
|
const req = await api.post<IFileTask[]>("/file-tasks");
|
||||||
): Promise<IPagination<IFileTask>> {
|
|
||||||
const req = await api.post("/file-tasks", { ...params });
|
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { zodResolver } from 'mantine-form-zod-resolver';
|
import { zodResolver } from 'mantine-form-zod-resolver';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(2).max(100),
|
name: z.string().trim().min(2).max(50),
|
||||||
description: z.string().max(500),
|
description: z.string().max(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(100),
|
name: z.string().min(2).max(50),
|
||||||
description: z.string().max(500),
|
description: z.string().max(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import Paginate from "@/components/common/paginate.tsx";
|
|||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { getSpaces } from "@/features/space/services/space-service.ts";
|
import { getSpaces } from "@/features/space/services/space-service.ts";
|
||||||
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|
||||||
|
|
||||||
export default function GroupList() {
|
export default function GroupList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -51,10 +50,10 @@ export default function GroupList() {
|
|||||||
>
|
>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<IconGroupCircle />
|
<IconGroupCircle />
|
||||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
<div>
|
||||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
{group.name}
|
{group.name}
|
||||||
</AutoTooltipText>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{group.description}
|
{group.description}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) {
|
|||||||
mainEditorTitle
|
mainEditorTitle
|
||||||
.chain()
|
.chain()
|
||||||
.clearContent()
|
.clearContent()
|
||||||
.setContent(activeHistoryData.title, { emitUpdate: true })
|
.setContent(activeHistoryData.title, true)
|
||||||
.run();
|
.run();
|
||||||
mainEditor
|
mainEditor
|
||||||
.chain()
|
.chain()
|
||||||
|
|||||||
@@ -9,14 +9,20 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
|
IconSearch,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
|
import {
|
||||||
|
getHotkeyHandler,
|
||||||
|
useClipboard,
|
||||||
|
useDisclosure,
|
||||||
|
useHotkeys,
|
||||||
|
} from "@mantine/hooks";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@@ -32,7 +38,8 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
||||||
|
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||||
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
@@ -44,6 +51,7 @@ interface PageHeaderMenuProps {
|
|||||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toggleAside = useToggleAside();
|
const toggleAside = useToggleAside();
|
||||||
|
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
[
|
[
|
||||||
@@ -67,7 +75,17 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConnectionWarning />
|
{yjsConnectionStatus === "disconnected" && (
|
||||||
|
<Tooltip
|
||||||
|
label={t("Real-time editor connection lost. Retrying...")}
|
||||||
|
openDelay={250}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
|
||||||
|
<IconWifiOff size={20} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||||
|
|
||||||
@@ -272,49 +290,3 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionWarning() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
|
||||||
const [showWarning, setShowWarning] = useState(false);
|
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isDisconnected = ["disconnected", "connecting"].includes(yjsConnectionStatus);
|
|
||||||
|
|
||||||
if (isDisconnected) {
|
|
||||||
if (!timeoutRef.current) {
|
|
||||||
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
setShowWarning(false);
|
|
||||||
}
|
|
||||||
}, [yjsConnectionStatus]);
|
|
||||||
|
|
||||||
// Cleanup only on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!showWarning) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
label={t("Real-time editor connection lost. Retrying...")}
|
|
||||||
openDelay={250}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
|
|
||||||
<IconWifiOff size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -94,9 +94,9 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
spaceId,
|
spaceId,
|
||||||
});
|
});
|
||||||
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
||||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>(null);
|
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
||||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||||
const rootElement = useRef<HTMLDivElement>(null);
|
const rootElement = useRef<HTMLDivElement>();
|
||||||
const [isRootReady, setIsRootReady] = useState(false);
|
const [isRootReady, setIsRootReady] = useState(false);
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
const mergedRef = useMergedRef((element) => {
|
const mergedRef = useMergedRef((element) => {
|
||||||
@@ -269,15 +269,12 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||||
|
|
||||||
const prefetchPage = () => {
|
const prefetchPage = () => {
|
||||||
timerRef.current = setTimeout(async () => {
|
timerRef.current = setTimeout(() => {
|
||||||
const page = await queryClient.fetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["pages", node.data.id],
|
queryKey: ["pages", node.data.slugId],
|
||||||
queryFn: () => getPageById({ pageId: node.data.id }),
|
queryFn: () => getPageById({ pageId: node.data.slugId }),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
if (page?.slugId) {
|
|
||||||
queryClient.setQueryData(["pages", page.slugId], page);
|
|
||||||
}
|
|
||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
|
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
@@ -20,12 +21,12 @@ import {
|
|||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { extractPageSlugId, getPageIcon } from "@/lib";
|
import { extractPageSlugId, getPageIcon } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
|
||||||
import CopyTextButton from "@/components/common/copy.tsx";
|
import CopyTextButton from "@/components/common/copy.tsx";
|
||||||
import { getAppUrl, isCloud } from "@/lib/config.ts";
|
import { getAppUrl, isCloud } from "@/lib/config.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import classes from "@/features/share/components/share.module.css";
|
import classes from "@/features/share/components/share.module.css";
|
||||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||||
|
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||||
|
|
||||||
interface ShareModalProps {
|
interface ShareModalProps {
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
@@ -34,9 +35,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const pageSlugId = extractPageSlugId(pageSlug);
|
const pageId = extractPageSlugId(pageSlug);
|
||||||
const { data: page } = usePageQuery({ pageId: pageSlugId });
|
|
||||||
const pageId = page?.id;
|
|
||||||
const { data: share } = useShareForPageQuery(pageId);
|
const { data: share } = useShareForPageQuery(pageId);
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { isTrial } = useTrial();
|
const { isTrial } = useTrial();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function SharedTree({ sharedPageTree }: SharedTree) {
|
|||||||
const [tree, setTree] = useState<
|
const [tree, setTree] = useState<
|
||||||
TreeApi<SharedPageTreeNode> | null | undefined
|
TreeApi<SharedPageTreeNode> | null | undefined
|
||||||
>(null);
|
>(null);
|
||||||
const rootElement = useRef<HTMLDivElement>(null);
|
const rootElement = useRef<HTMLDivElement>();
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ import {
|
|||||||
getShares,
|
getShares,
|
||||||
updateShare,
|
updateShare,
|
||||||
} from "@/features/share/services/share-service.ts";
|
} from "@/features/share/services/share-service.ts";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export function useGetSharesQuery(
|
export function useGetSharesQuery(
|
||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
@@ -70,7 +72,7 @@ export function useShareForPageQuery(
|
|||||||
queryKey: ["share-for-page", pageId],
|
queryKey: ["share-for-page", pageId],
|
||||||
queryFn: () => getShareForPage(pageId),
|
queryFn: () => getShareForPage(pageId),
|
||||||
enabled: !!pageId,
|
enabled: !!pageId,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 0,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -10,12 +9,12 @@ import { getSpaceUrl } from "@/lib/config.ts";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(2).max(100),
|
name: z.string().trim().min(2).max(50),
|
||||||
slug: z
|
slug: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(2)
|
.min(2)
|
||||||
.max(100)
|
.max(50)
|
||||||
.regex(
|
.regex(
|
||||||
/^[a-zA-Z0-9]+$/,
|
/^[a-zA-Z0-9]+$/,
|
||||||
"Space slug must be alphanumeric. No special characters",
|
"Space slug must be alphanumeric. No special characters",
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import { ISpace } from "@/features/space/types/space.types.ts";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(100),
|
name: z.string().min(2).max(50),
|
||||||
description: z.string().max(500),
|
description: z.string().max(250),
|
||||||
slug: z
|
slug: z
|
||||||
.string()
|
.string()
|
||||||
.min(2)
|
.min(2)
|
||||||
.max(100)
|
.max(50)
|
||||||
.regex(
|
.regex(
|
||||||
/^[a-zA-Z0-9]+$/,
|
/^[a-zA-Z0-9]+$/,
|
||||||
"Space slug must be alphanumeric. No special characters",
|
"Space slug must be alphanumeric. No special characters",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|
||||||
|
|
||||||
export default function SpaceList() {
|
export default function SpaceList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -49,10 +48,10 @@ export default function SpaceList() {
|
|||||||
variant="filled"
|
variant="filled"
|
||||||
name={space.name}
|
name={space.name}
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
<div>
|
||||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
{space.name}
|
{space.name}
|
||||||
</AutoTooltipText>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{space.description}
|
{space.description}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|
||||||
|
|
||||||
type MemberType = "user" | "group";
|
type MemberType = "user" | "group";
|
||||||
|
|
||||||
@@ -139,10 +138,10 @@ export default function SpaceMembersList({
|
|||||||
|
|
||||||
{member.type === "group" && <IconGroupCircle />}
|
{member.type === "group" && <IconGroupCircle />}
|
||||||
|
|
||||||
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 260 }}>
|
<div>
|
||||||
<AutoTooltipText fz="sm" fw={500}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
{member?.name}
|
{member?.name}
|
||||||
</AutoTooltipText>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed">
|
<Text fz="xs" c="dimmed">
|
||||||
{member.type == "user" && member?.email}
|
{member.type == "user" && member?.email}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
|||||||
import classes from "./all-spaces-list.module.css";
|
import classes from "./all-spaces-list.module.css";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|
||||||
|
|
||||||
interface AllSpacesListProps {
|
interface AllSpacesListProps {
|
||||||
spaces: any[];
|
spaces: any[];
|
||||||
@@ -97,10 +96,10 @@ export default function AllSpacesList({
|
|||||||
variant="filled"
|
variant="filled"
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 350 }}>
|
<div>
|
||||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
{space.name}
|
{space.name}
|
||||||
</AutoTooltipText>
|
</Text>
|
||||||
{space.description && (
|
{space.description && (
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{space.description}
|
{space.description}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { MantineColor } from "@mantine/core";
|
|
||||||
|
|
||||||
function hashCode(input: string) {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < input.length; i += 1) {
|
|
||||||
const char = input.charCodeAt(i);
|
|
||||||
hash = (hash << 5) - hash + char;
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultColors: MantineColor[] = [
|
|
||||||
"blue",
|
|
||||||
"cyan",
|
|
||||||
"grape",
|
|
||||||
"green",
|
|
||||||
"indigo",
|
|
||||||
"lime",
|
|
||||||
"orange",
|
|
||||||
"pink",
|
|
||||||
"red",
|
|
||||||
"teal",
|
|
||||||
"violet",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getInitialsColor(
|
|
||||||
name: string,
|
|
||||||
colors: MantineColor[] = defaultColors,
|
|
||||||
) {
|
|
||||||
const hash = hashCode(name);
|
|
||||||
const index = Math.abs(hash) % colors.length;
|
|
||||||
return colors[index];
|
|
||||||
}
|
|
||||||
+27
-30
@@ -30,80 +30,76 @@
|
|||||||
"test:e2e": "jest --config test/jest-e2e.json"
|
"test:e2e": "jest --config test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/google": "^3.0.9",
|
"@ai-sdk/azure": "^2.0.47",
|
||||||
"@ai-sdk/openai": "^3.0.11",
|
"@ai-sdk/google": "^2.0.18",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.12",
|
"@ai-sdk/openai": "^2.0.46",
|
||||||
"@aws-sdk/client-s3": "3.701.0",
|
"@aws-sdk/client-s3": "3.701.0",
|
||||||
"@aws-sdk/lib-storage": "3.701.0",
|
"@aws-sdk/lib-storage": "3.701.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.701.0",
|
"@aws-sdk/s3-request-presigner": "3.701.0",
|
||||||
|
"@casl/ability": "^6.7.3",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^9.3.0",
|
"@fastify/multipart": "^9.0.3",
|
||||||
"@fastify/static": "^8.3.0",
|
"@fastify/static": "^8.2.0",
|
||||||
"@langchain/core": "1.1.13",
|
"@langchain/textsplitters": "^0.1.0",
|
||||||
"@langchain/textsplitters": "1.0.1",
|
|
||||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/common": "^11.1.11",
|
"@nestjs/common": "^11.1.9",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.1.11",
|
"@nestjs/core": "^11.1.9",
|
||||||
"@nestjs/event-emitter": "^3.0.1",
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
"@nestjs/jwt": "11.0.0",
|
"@nestjs/jwt": "11.0.0",
|
||||||
"@nestjs/mapped-types": "^2.1.0",
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-fastify": "^11.1.11",
|
"@nestjs/platform-fastify": "^11.1.9",
|
||||||
"@nestjs/platform-socket.io": "^11.1.11",
|
"@nestjs/platform-socket.io": "^11.1.9",
|
||||||
"@nestjs/schedule": "^6.1.0",
|
"@nestjs/schedule": "^6.0.1",
|
||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/websockets": "^11.1.11",
|
"@nestjs/websockets": "^11.1.9",
|
||||||
"@node-saml/passport-saml": "^5.1.0",
|
"@node-saml/passport-saml": "^5.1.0",
|
||||||
"@react-email/components": "0.0.28",
|
"@react-email/components": "0.0.28",
|
||||||
"@react-email/render": "1.0.2",
|
"@react-email/render": "1.0.2",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"ai": "^6.0.37",
|
"ai": "^5.0.65",
|
||||||
"ai-sdk-ollama": "^3.1.1",
|
"ai-sdk-ollama": "^0.12.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.65.0",
|
"bullmq": "^5.65.0",
|
||||||
"cache-manager": "^6.4.3",
|
"cache-manager": "^6.4.3",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"cookie": "^1.1.1",
|
"cookie": "^1.0.2",
|
||||||
"fs-extra": "^11.3.3",
|
"fs-extra": "^11.3.0",
|
||||||
"happy-dom": "20.1.0",
|
"happy-dom": "20.0.10",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"kysely": "^0.28.2",
|
"kysely": "^0.28.2",
|
||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
"kysely-postgres-js": "^3.0.0",
|
|
||||||
"ldapts": "^7.4.0",
|
"ldapts": "^7.4.0",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"nestjs-kysely": "^1.2.0",
|
"nestjs-kysely": "^1.2.0",
|
||||||
"nestjs-pino": "^4.5.0",
|
"nodemailer": "^7.0.11",
|
||||||
"nodemailer": "^7.0.12",
|
|
||||||
"openid-client": "^5.7.1",
|
"openid-client": "^5.7.1",
|
||||||
"otpauth": "^9.4.1",
|
"otpauth": "^9.4.0",
|
||||||
"p-limit": "^6.2.0",
|
"p-limit": "^6.2.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfjs-dist": "^5.4.394",
|
"pdfjs-dist": "^5.4.394",
|
||||||
|
"pg": "^8.16.3",
|
||||||
"pg-tsquery": "^8.4.2",
|
"pg-tsquery": "^8.4.2",
|
||||||
"pgvector": "^0.2.1",
|
"pgvector": "^0.2.1",
|
||||||
"postgres": "^3.4.8",
|
|
||||||
"pino-http": "^11.0.0",
|
|
||||||
"pino-pretty": "^13.1.3",
|
|
||||||
"postmark": "^4.0.5",
|
"postmark": "^4.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"sanitize-filename-ts": "1.0.2",
|
"sanitize-filename-ts": "1.0.2",
|
||||||
"sharp": "0.34.3",
|
"sharp": "0.34.3",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.1",
|
||||||
"stripe": "^17.5.0",
|
"stripe": "^17.5.0",
|
||||||
"tmp-promise": "^3.0.3",
|
"tmp-promise": "^3.0.3",
|
||||||
"typesense": "^2.1.0",
|
"typesense": "^2.1.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.18.3",
|
||||||
"yauzl": "^3.2.0"
|
"yauzl": "^3.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -120,6 +116,7 @@
|
|||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/passport-google-oauth20": "^2.0.16",
|
"@types/passport-google-oauth20": "^2.0.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/pg": "^8.11.11",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"@types/yauzl": "^2.10.3",
|
"@types/yauzl": "^2.10.3",
|
||||||
@@ -127,7 +124,7 @@
|
|||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"globals": "^15.15.0",
|
"globals": "^15.15.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"kysely-codegen": "^0.19.0",
|
"kysely-codegen": "^0.17.0",
|
||||||
"prettier": "^3.5.1",
|
"prettier": "^3.5.1",
|
||||||
"react-email": "3.0.2",
|
"react-email": "3.0.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { SecurityModule } from './integrations/security/security.module';
|
|||||||
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
||||||
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
||||||
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
||||||
import { LoggerModule } from './common/logger/logger.module';
|
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -36,7 +35,6 @@ try {
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
LoggerModule,
|
|
||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class CollaborationGateway {
|
|||||||
) {
|
) {
|
||||||
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||||
|
|
||||||
this.hocuspocus = new Hocuspocus({
|
this.hocuspocus = HocuspocusServer.configure({
|
||||||
debounce: 10000,
|
debounce: 10000,
|
||||||
maxDebounce: 45000,
|
maxDebounce: 45000,
|
||||||
unloadImmediately: false,
|
unloadImmediately: false,
|
||||||
@@ -65,6 +65,6 @@ export class CollaborationGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
//await this.hocuspocus.destroy();
|
await this.hocuspocus.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { StarterKit } from '@tiptap/starter-kit';
|
import { StarterKit } from '@tiptap/starter-kit';
|
||||||
import { TextAlign } from '@tiptap/extension-text-align';
|
import { TextAlign } from '@tiptap/extension-text-align';
|
||||||
|
import { TaskList } from '@tiptap/extension-task-list';
|
||||||
|
import { TaskItem } from '@tiptap/extension-task-item';
|
||||||
|
import { Underline } from '@tiptap/extension-underline';
|
||||||
import { Superscript } from '@tiptap/extension-superscript';
|
import { Superscript } from '@tiptap/extension-superscript';
|
||||||
import SubScript from '@tiptap/extension-subscript';
|
import SubScript from '@tiptap/extension-subscript';
|
||||||
import { Typography } from '@tiptap/extension-typography';
|
import { Typography } from '@tiptap/extension-typography';
|
||||||
import { TextStyle } from '@tiptap/extension-text-style';
|
import { TextStyle } from '@tiptap/extension-text-style';
|
||||||
import { Color } from '@tiptap/extension-color';
|
import { Color } from '@tiptap/extension-color';
|
||||||
import { Youtube } from '@tiptap/extension-youtube';
|
import { Youtube } from '@tiptap/extension-youtube';
|
||||||
import { TaskList, TaskItem } from '@tiptap/extension-list';
|
|
||||||
import {
|
import {
|
||||||
Heading,
|
Heading,
|
||||||
Callout,
|
Callout,
|
||||||
@@ -40,14 +42,11 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
|||||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||||
//import { generateJSON } from '@tiptap/html';
|
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
link: false,
|
|
||||||
trailingNode: false,
|
|
||||||
heading: false,
|
heading: false,
|
||||||
}),
|
}),
|
||||||
Heading,
|
Heading,
|
||||||
@@ -60,6 +59,7 @@ export const tiptapExtensions = [
|
|||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
|
Underline,
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
Superscript,
|
Superscript,
|
||||||
SubScript,
|
SubScript,
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (userSpaceRole === SpaceRole.READER) {
|
if (userSpaceRole === SpaceRole.READER) {
|
||||||
data.connectionConfig.readOnly = true;
|
data.connection.readOnly = true;
|
||||||
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ import { QueueModule } from '../../integrations/queue/queue.module';
|
|||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { HealthModule } from '../../integrations/health/health.module';
|
import { HealthModule } from '../../integrations/health/health.module';
|
||||||
import { CollaborationController } from './collaboration.controller';
|
import { CollaborationController } from './collaboration.controller';
|
||||||
import { LoggerModule } from '../../common/logger/logger.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
LoggerModule,
|
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
CollaborationModule,
|
CollaborationModule,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
NestFastifyApplication,
|
NestFastifyApplication,
|
||||||
} from '@nestjs/platform-fastify';
|
} from '@nestjs/platform-fastify';
|
||||||
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
|
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
|
||||||
|
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Logger as PinoLogger } from 'nestjs-pino';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
@@ -17,12 +17,10 @@ async function bootstrap() {
|
|||||||
maxParamLength: 500,
|
maxParamLength: 500,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
bufferLogs: true,
|
logger: new InternalLogFilter(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.useLogger(app.get(PinoLogger));
|
|
||||||
|
|
||||||
app.setGlobalPrefix('api', { exclude: ['/'] });
|
app.setGlobalPrefix('api', { exclude: ['/'] });
|
||||||
|
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export enum EventName {
|
export enum EventName {
|
||||||
COLLAB_PAGE_UPDATED = 'collab.page.updated',
|
COLLAB_PAGE_UPDATED = 'collab.page.updated',
|
||||||
|
|
||||||
PAGE_CREATED = 'page.created',
|
PAGE_CREATED = 'page.created',
|
||||||
PAGE_UPDATED = 'page.updated',
|
PAGE_UPDATED = 'page.updated',
|
||||||
PAGE_CONTENT_UPDATED = 'page-content-updated',
|
PAGE_CONTENT_UPDATED = 'page-content-updated',
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
// https://github.com/WebReflection/html-escaper
|
|
||||||
/**
|
|
||||||
* Copyright (C) 2017-present by Andrea Giammarchi - @WebReflection
|
|
||||||
*
|
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
* of this software and associated documentation files (the "Software"), to deal
|
|
||||||
* in the Software without restriction, including without limitation the rights
|
|
||||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
* copies of the Software, and to permit persons to whom the Software is
|
|
||||||
* furnished to do so, subject to the following conditions:
|
|
||||||
*
|
|
||||||
* The above copyright notice and this permission notice shall be included in
|
|
||||||
* all copies or substantial portions of the Software.
|
|
||||||
*
|
|
||||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
* THE SOFTWARE.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { replace } = '';
|
|
||||||
|
|
||||||
// escape
|
|
||||||
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g;
|
|
||||||
const ca = /[&<>'"]/g;
|
|
||||||
|
|
||||||
const esca = {
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
"'": ''',
|
|
||||||
'"': '"',
|
|
||||||
};
|
|
||||||
const pe = (m) => esca[m];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
|
|
||||||
* @param {string} es the input to safely escape
|
|
||||||
* @returns {string} the escaped input, and it **throws** an error if
|
|
||||||
* the input type is unexpected, except for boolean and numbers,
|
|
||||||
* converted as string.
|
|
||||||
*/
|
|
||||||
export const htmlEscape = (es) => replace.call(es, ca, pe);
|
|
||||||
|
|
||||||
// unescape
|
|
||||||
const unes = {
|
|
||||||
'&': '&',
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'>': '>',
|
|
||||||
''': "'",
|
|
||||||
''': "'",
|
|
||||||
'"': '"',
|
|
||||||
'"': '"',
|
|
||||||
};
|
|
||||||
const cape = (m) => unes[m];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely unescape previously escaped entities such as `&`, `<`, `>`, `"`,
|
|
||||||
* and `'`.
|
|
||||||
* @param {string} un a previously escaped string
|
|
||||||
* @returns {string} the unescaped input, and it **throws** an error if
|
|
||||||
* the input type is unexpected, except for boolean and numbers,
|
|
||||||
* converted as string.
|
|
||||||
*/
|
|
||||||
export const htmlUnescape = (un) => replace.call(un, es, cape);
|
|
||||||
@@ -5,4 +5,4 @@ export const nanoIdGen = customAlphabet(alphabet, 10);
|
|||||||
|
|
||||||
const slugIdAlphabet =
|
const slugIdAlphabet =
|
||||||
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||||
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);
|
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export enum SpaceRole {
|
|||||||
READER = 'reader', // can only read pages in space
|
READER = 'reader', // can only read pages in space
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PageRole {
|
||||||
|
WRITER = 'writer', // can read and write pages in space
|
||||||
|
READER = 'reader', // can only read pages in space
|
||||||
|
RESTRICTED = 'restricted', // cannot access page
|
||||||
|
}
|
||||||
|
|
||||||
export enum SpaceVisibility {
|
export enum SpaceVisibility {
|
||||||
OPEN = 'open', // any workspace member can see that it exists and join.
|
OPEN = 'open', // any workspace member can see that it exists and join.
|
||||||
PRIVATE = 'private', // only added space users can see
|
PRIVATE = 'private', // only added space users can see
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import * as path from 'path';
|
|||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { sanitize } from 'sanitize-filename-ts';
|
import { sanitize } from 'sanitize-filename-ts';
|
||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
import { Readable, Transform } from 'stream';
|
|
||||||
|
|
||||||
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
||||||
|
|
||||||
@@ -99,38 +98,3 @@ export function hasLicenseOrEE(opts: {
|
|||||||
const { licenseKey, plan, isCloud } = opts;
|
const { licenseKey, plan, isCloud } = opts;
|
||||||
return Boolean(licenseKey) || (isCloud && plan === 'business');
|
return Boolean(licenseKey) || (isCloud && plan === 'business');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes a database URL for postgres.js compatibility.
|
|
||||||
* - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values
|
|
||||||
* - Removes `schema` parameter (has no effect via connection string)
|
|
||||||
* Note: If we don't strip them, the connection will fail
|
|
||||||
*/
|
|
||||||
export function normalizePostgresUrl(url: string): string {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const newParams = new URLSearchParams();
|
|
||||||
|
|
||||||
for (const [key, value] of parsed.searchParams) {
|
|
||||||
if (key === 'sslmode' && value === 'no-verify') continue;
|
|
||||||
if (key === 'schema') continue;
|
|
||||||
newParams.append(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed.search = newParams.toString();
|
|
||||||
return parsed.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createByteCountingStream(source: Readable) {
|
|
||||||
let bytesRead = 0;
|
|
||||||
const stream = new Transform({
|
|
||||||
transform(chunk, encoding, callback) {
|
|
||||||
bytesRead += chunk.length;
|
|
||||||
callback(null, chunk);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
source.pipe(stream);
|
|
||||||
source.on('error', (err) => stream.emit('error', err));
|
|
||||||
|
|
||||||
return { stream, getBytesRead: () => bytesRead };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,11 +14,18 @@ export class InternalLogFilter extends ConsoleLogger {
|
|||||||
super();
|
super();
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
||||||
|
|
||||||
if (isProduction && !isDebugMode) {
|
if (isProduction && !isDebugMode) {
|
||||||
this.allowedLogLevels = ['log', 'error', 'fatal'];
|
this.allowedLogLevels = ['log', 'error', 'fatal'];
|
||||||
} else {
|
} else {
|
||||||
this.allowedLogLevels = ['log', 'debug', 'verbose', 'warn', 'error', 'fatal'];
|
this.allowedLogLevels = [
|
||||||
|
'log',
|
||||||
|
'debug',
|
||||||
|
'verbose',
|
||||||
|
'warn',
|
||||||
|
'error',
|
||||||
|
'fatal',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { LoggerModule as PinoLoggerModule } from 'nestjs-pino';
|
|
||||||
import { createPinoConfig } from './pino.config';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PinoLoggerModule.forRoot(createPinoConfig())],
|
|
||||||
exports: [PinoLoggerModule],
|
|
||||||
})
|
|
||||||
export class LoggerModule {}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { Params } from 'nestjs-pino';
|
|
||||||
import { stdTimeFunctions } from 'pino';
|
|
||||||
|
|
||||||
const CONTEXTS_TO_IGNORE = [
|
|
||||||
'InstanceLoader',
|
|
||||||
'RoutesResolver',
|
|
||||||
'RouterExplorer',
|
|
||||||
'WebSocketsController',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createPinoConfig(): Params {
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
|
||||||
const logHttp = process.env.LOG_HTTP === 'true';
|
|
||||||
|
|
||||||
const level = isProduction && !isDebugMode ? 'info' : 'debug';
|
|
||||||
|
|
||||||
return {
|
|
||||||
pinoHttp: {
|
|
||||||
level,
|
|
||||||
timestamp: stdTimeFunctions.isoTime,
|
|
||||||
transport: !isProduction
|
|
||||||
? {
|
|
||||||
target: 'pino-pretty',
|
|
||||||
options: {
|
|
||||||
colorize: true,
|
|
||||||
singleLine: true,
|
|
||||||
translateTime: 'SYS:standard',
|
|
||||||
ignore: 'pid,hostname',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
formatters: {
|
|
||||||
level: (label) => ({ level: label }),
|
|
||||||
log: (object: Record<string, unknown>) => {
|
|
||||||
if (isProduction && !isDebugMode) {
|
|
||||||
const context = object['context'] as string | undefined;
|
|
||||||
if (context && CONTEXTS_TO_IGNORE.includes(context)) {
|
|
||||||
return { filtered: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return object;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
serializers: {
|
|
||||||
req: (req) => {
|
|
||||||
const forwardedFor = req.headers?.['x-forwarded-for'];
|
|
||||||
const ip =
|
|
||||||
req.headers?.['cf-connecting-ip'] ||
|
|
||||||
(typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() : undefined) ||
|
|
||||||
req.remoteAddress;
|
|
||||||
|
|
||||||
return {
|
|
||||||
method: req.method,
|
|
||||||
url: req.url,
|
|
||||||
ip,
|
|
||||||
userAgent: req.headers?.['user-agent'],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
res: (res) => ({
|
|
||||||
statusCode: res.statusCode,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
customLogLevel: (_req, res, err) => {
|
|
||||||
if (res.statusCode >= 500 || err) return 'error';
|
|
||||||
if (res.statusCode >= 400) return 'warn';
|
|
||||||
return 'info';
|
|
||||||
},
|
|
||||||
autoLogging: logHttp
|
|
||||||
? {
|
|
||||||
ignore: (req) =>
|
|
||||||
req.url === '/api/health' || req.url === '/api/health/live',
|
|
||||||
}
|
|
||||||
: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -5,17 +5,15 @@ import { sanitizeFileName } from '../../common/helpers';
|
|||||||
import * as sharp from 'sharp';
|
import * as sharp from 'sharp';
|
||||||
|
|
||||||
export interface PreparedFile {
|
export interface PreparedFile {
|
||||||
buffer?: Buffer;
|
buffer: Buffer;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
fileExtension: string;
|
fileExtension: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
multiPartFile?: MultipartFile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prepareFile(
|
export async function prepareFile(
|
||||||
filePromise: Promise<MultipartFile>,
|
filePromise: Promise<MultipartFile>,
|
||||||
options: { skipBuffer?: boolean } = {},
|
|
||||||
): Promise<PreparedFile> {
|
): Promise<PreparedFile> {
|
||||||
const file = await filePromise;
|
const file = await filePromise;
|
||||||
|
|
||||||
@@ -24,16 +22,10 @@ export async function prepareFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let buffer: Buffer | undefined;
|
const buffer = await file.toBuffer();
|
||||||
let fileSize = 0;
|
|
||||||
|
|
||||||
if (!options.skipBuffer) {
|
|
||||||
buffer = await file.toBuffer();
|
|
||||||
fileSize = buffer.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitizedFilename = sanitizeFileName(file.filename);
|
const sanitizedFilename = sanitizeFileName(file.filename);
|
||||||
const fileName = sanitizedFilename.slice(0, 255);
|
const fileName = sanitizedFilename.slice(0, 255);
|
||||||
|
const fileSize = buffer.length;
|
||||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -42,7 +34,6 @@ export async function prepareFile(
|
|||||||
fileSize,
|
fileSize,
|
||||||
fileExtension,
|
fileExtension,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
multiPartFile: file,
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Readable } from 'stream';
|
|
||||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||||
import { MultipartFile } from '@fastify/multipart';
|
import { MultipartFile } from '@fastify/multipart';
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +26,6 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { createByteCountingStream } from '../../../common/helpers/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AttachmentService {
|
export class AttachmentService {
|
||||||
@@ -51,9 +49,7 @@ export class AttachmentService {
|
|||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
|
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
|
||||||
const preparedFile: PreparedFile = await prepareFile(filePromise, {
|
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||||
skipBuffer: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
let isUpdate = false;
|
let isUpdate = false;
|
||||||
let attachmentId = null;
|
let attachmentId = null;
|
||||||
@@ -85,14 +81,7 @@ export class AttachmentService {
|
|||||||
|
|
||||||
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
|
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
|
||||||
|
|
||||||
const { stream, getBytesRead } = createByteCountingStream(
|
await this.uploadToDrive(filePath, preparedFile.buffer);
|
||||||
preparedFile.multiPartFile.file,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.uploadToDrive(filePath, stream);
|
|
||||||
|
|
||||||
// Update fileSize from the consumed stream
|
|
||||||
preparedFile.fileSize = getBytesRead();
|
|
||||||
|
|
||||||
let attachment: Attachment = null;
|
let attachment: Attachment = null;
|
||||||
try {
|
try {
|
||||||
@@ -153,10 +142,7 @@ export class AttachmentService {
|
|||||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||||
validateFileType(preparedFile.fileExtension, validImageExtensions);
|
validateFileType(preparedFile.fileExtension, validImageExtensions);
|
||||||
|
|
||||||
const processedBuffer = await compressAndResizeIcon(
|
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
|
||||||
preparedFile.buffer,
|
|
||||||
type,
|
|
||||||
);
|
|
||||||
preparedFile.buffer = processedBuffer;
|
preparedFile.buffer = processedBuffer;
|
||||||
preparedFile.fileSize = processedBuffer.length;
|
preparedFile.fileSize = processedBuffer.length;
|
||||||
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
||||||
@@ -246,9 +232,9 @@ export class AttachmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadToDrive(filePath: string, fileContent: Buffer | Readable) {
|
async uploadToDrive(filePath: string, fileBuffer: any) {
|
||||||
try {
|
try {
|
||||||
await this.storageService.upload(filePath, fileContent);
|
await this.storageService.upload(filePath, fileBuffer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Error uploading file to drive:', err);
|
this.logger.error('Error uploading file to drive:', err);
|
||||||
throw new BadRequestException('Error uploading file to drive');
|
throw new BadRequestException('Error uploading file to drive');
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AbilityBuilder,
|
||||||
|
createMongoAbility,
|
||||||
|
MongoAbility,
|
||||||
|
} from '@casl/ability';
|
||||||
|
import { PageRole, SpaceRole } from '../../../common/helpers/types/permission';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import {
|
||||||
|
PagePermissionRepo,
|
||||||
|
PageMemberRole,
|
||||||
|
} from '@docmost/db/repos/page/page-permission-repo.service';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import {
|
||||||
|
PageCaslAction,
|
||||||
|
IPageAbility,
|
||||||
|
PageCaslSubject,
|
||||||
|
} from '../interfaces/page-ability.type';
|
||||||
|
import { findHighestUserSpaceRole } from '@docmost/db/repos/Space/utils';
|
||||||
|
import { UserSpaceRole } from '@docmost/db/repos/space/types';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class PageAbilityFactory {
|
||||||
|
private readonly logger = new Logger(PageAbilityFactory.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||||
|
private readonly pageRepo: PageRepo,
|
||||||
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createForUser(user: User, pageId: string) {
|
||||||
|
//user.id = '0197750c-a70c-73a6-83ad-65a193433f5c';
|
||||||
|
|
||||||
|
// This opens the possibility to share pages with individual users from other Spaces
|
||||||
|
|
||||||
|
/*
|
||||||
|
//TODO: we might account for space permission here too.
|
||||||
|
// we could just do it all here. no need to call two abilities.
|
||||||
|
const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles(
|
||||||
|
user.id,
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
|
||||||
|
// const userPageRole = findHighestUserPageRole(userPageRoles);
|
||||||
|
// if no role abort
|
||||||
|
|
||||||
|
// Check page-level permissions first if pageId provided
|
||||||
|
|
||||||
|
const permission = await this.pagePermissionRepo.getUserPagePermission({
|
||||||
|
pageId: pageId,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// does it pick one? what if the user has permissions via groups? what roles takes precedence?
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
//TODO: it means we should use the space level permission
|
||||||
|
// need deeper understanding here though
|
||||||
|
// call the space factory?
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('permissions', permission);
|
||||||
|
if (permission) {
|
||||||
|
// make sure the permission is for this page
|
||||||
|
// or cascaded/inherited from a parent page
|
||||||
|
/*this.logger.debug('role', permission.role, 'cascade', permission.cascade);
|
||||||
|
if (permission.pageId !== pageId && !permission.cascade) {
|
||||||
|
this.logger.debug('no permission');
|
||||||
|
// No explicit access and not inheriting - deny
|
||||||
|
return new AbilityBuilder<MongoAbility<IPageAbility>>(
|
||||||
|
createMongoAbility,
|
||||||
|
).build();
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no permission should we use space permission here?
|
||||||
|
// if non, skip for default to take precedence
|
||||||
|
|
||||||
|
switch (permission.role) {
|
||||||
|
case PageRole.WRITER:
|
||||||
|
return buildPageWriterAbility();
|
||||||
|
case PageRole.READER:
|
||||||
|
return buildPageReaderAbility();
|
||||||
|
case PageRole.RESTRICTED:
|
||||||
|
return buildPageRestrictedAbility();
|
||||||
|
default:
|
||||||
|
throw new NotFoundException('Page permissions not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAbilityForRole(role: string) {
|
||||||
|
switch (role) {
|
||||||
|
case PageRole.WRITER:
|
||||||
|
return buildPageWriterAbility();
|
||||||
|
case PageRole.READER:
|
||||||
|
return buildPageReaderAbility();
|
||||||
|
case PageRole.RESTRICTED:
|
||||||
|
return buildPageRestrictedAbility();
|
||||||
|
default:
|
||||||
|
return new AbilityBuilder<MongoAbility<IPageAbility>>(
|
||||||
|
createMongoAbility,
|
||||||
|
).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageWriterAbility() {
|
||||||
|
const { can, build } = new AbilityBuilder<MongoAbility<IPageAbility>>(
|
||||||
|
createMongoAbility,
|
||||||
|
);
|
||||||
|
can(PageCaslAction.Read, PageCaslSubject.Settings);
|
||||||
|
can(PageCaslAction.Read, PageCaslSubject.Member);
|
||||||
|
can(PageCaslAction.Manage, PageCaslSubject.Page);
|
||||||
|
can(PageCaslAction.Manage, PageCaslSubject.Share);
|
||||||
|
return build();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageReaderAbility() {
|
||||||
|
const { can, build } = new AbilityBuilder<MongoAbility<IPageAbility>>(
|
||||||
|
createMongoAbility,
|
||||||
|
);
|
||||||
|
can(PageCaslAction.Read, PageCaslSubject.Settings);
|
||||||
|
can(PageCaslAction.Read, PageCaslSubject.Member);
|
||||||
|
can(PageCaslAction.Read, PageCaslSubject.Page);
|
||||||
|
can(PageCaslAction.Read, PageCaslSubject.Share);
|
||||||
|
return build();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageRestrictedAbility() {
|
||||||
|
const { cannot, build } = new AbilityBuilder<MongoAbility<IPageAbility>>(
|
||||||
|
createMongoAbility,
|
||||||
|
);
|
||||||
|
cannot(PageCaslAction.Read, PageCaslSubject.Settings);
|
||||||
|
cannot(PageCaslAction.Read, PageCaslSubject.Member);
|
||||||
|
cannot(PageCaslAction.Read, PageCaslSubject.Page);
|
||||||
|
cannot(PageCaslAction.Read, PageCaslSubject.Share);
|
||||||
|
return build();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPageRole {
|
||||||
|
userId: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findHighestUserPageRole(userPageRoles: UserPageRole[]) {
|
||||||
|
//TODO: perhaps, we want the lowest here?
|
||||||
|
if (!userPageRoles) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleOrder: { [key in PageRole]: number } = {
|
||||||
|
[PageRole.WRITER]: 3,
|
||||||
|
[PageRole.READER]: 2,
|
||||||
|
[PageRole.RESTRICTED]: 1,
|
||||||
|
};
|
||||||
|
let highestRole: string;
|
||||||
|
|
||||||
|
for (const userPageRole of userPageRoles) {
|
||||||
|
const currentRole = userPageRole.role;
|
||||||
|
if (!highestRole || roleOrder[currentRole] > roleOrder[highestRole]) {
|
||||||
|
highestRole = currentRole;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return highestRole;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import SpaceAbilityFactory from './abilities/space-ability.factory';
|
import SpaceAbilityFactory from './abilities/space-ability.factory';
|
||||||
import WorkspaceAbilityFactory from './abilities/workspace-ability.factory';
|
import WorkspaceAbilityFactory from './abilities/workspace-ability.factory';
|
||||||
|
import PageAbilityFactory from './abilities/page-ability.factory';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [WorkspaceAbilityFactory, SpaceAbilityFactory],
|
providers: [WorkspaceAbilityFactory, SpaceAbilityFactory, PageAbilityFactory],
|
||||||
exports: [WorkspaceAbilityFactory, SpaceAbilityFactory],
|
exports: [WorkspaceAbilityFactory, SpaceAbilityFactory, PageAbilityFactory],
|
||||||
})
|
})
|
||||||
export class CaslModule {}
|
export class CaslModule {}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export enum PageCaslAction {
|
||||||
|
Manage = 'manage',
|
||||||
|
Create = 'create',
|
||||||
|
Read = 'read',
|
||||||
|
Edit = 'edit',
|
||||||
|
Delete = 'delete',
|
||||||
|
}
|
||||||
|
export enum PageCaslSubject {
|
||||||
|
Settings = 'settings',
|
||||||
|
Member = 'member',
|
||||||
|
Page = 'page',
|
||||||
|
Share = 'share',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IPageAbility =
|
||||||
|
| [PageCaslAction, PageCaslSubject.Settings]
|
||||||
|
| [PageCaslAction, PageCaslSubject.Member]
|
||||||
|
| [PageCaslAction, PageCaslSubject.Page]
|
||||||
|
| [PageCaslAction, PageCaslSubject.Share];
|
||||||
@@ -7,11 +7,11 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import {Transform, TransformFnParams} from "class-transformer";
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
|
||||||
export class CreateGroupDto {
|
export class CreateGroupDto {
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(100)
|
@MaxLength(50)
|
||||||
@IsString()
|
@IsString()
|
||||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsUUID,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { PageIdDto } from './page.dto';
|
||||||
|
import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service';
|
||||||
|
|
||||||
|
export class AddPageMembersDto extends PageIdDto {
|
||||||
|
@IsEnum(PageMemberRole)
|
||||||
|
role: string;
|
||||||
|
// optional
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(25, {
|
||||||
|
message: 'userIds must be an array with no more than 25 elements',
|
||||||
|
})
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
userIds: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(25, {
|
||||||
|
message: 'groupIds must be an array with no more than 25 elements',
|
||||||
|
})
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
groupIds: string[];
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
cascade?: boolean; // Apply to all child pages
|
||||||
|
}
|
||||||
@@ -4,4 +4,4 @@ export class DeletedPageDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export type CopyPageMapEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ICopyPageAttachment = {
|
export type ICopyPageAttachment = {
|
||||||
newPageId: string,
|
newPageId: string;
|
||||||
oldPageId: string,
|
oldPageId: string;
|
||||||
oldAttachmentId: string,
|
oldAttachmentId: string;
|
||||||
newAttachmentId: string,
|
newAttachmentId: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { IsOptional, IsNumber, IsString, Min, Max } from 'class-validator';
|
||||||
|
import { PageIdDto } from './page.dto';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class GetPageMembersDto extends PageIdDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 20;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
query?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
import { PageIdDto } from './page.dto';
|
||||||
|
|
||||||
|
export class RemovePageMemberDto extends PageIdDto {
|
||||||
|
@IsUUID()
|
||||||
|
memberId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsEnum, IsUUID } from 'class-validator';
|
||||||
|
import { PageIdDto } from './page.dto';
|
||||||
|
import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service';
|
||||||
|
|
||||||
|
export class UpdatePageMemberRoleDto extends PageIdDto {
|
||||||
|
@IsUUID()
|
||||||
|
memberId: string;
|
||||||
|
|
||||||
|
@IsEnum(PageMemberRole)
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsBoolean, IsEnum, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service';
|
||||||
|
|
||||||
|
export class UpdatePagePermissionDto {
|
||||||
|
@IsUUID()
|
||||||
|
pageId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
groupId?: string;
|
||||||
|
|
||||||
|
@IsEnum(PageMemberRole)
|
||||||
|
role: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
cascade: boolean; // Apply to all child pages
|
||||||
|
}
|
||||||
@@ -32,9 +32,24 @@ import {
|
|||||||
} from '../casl/interfaces/space-ability.type';
|
} from '../casl/interfaces/space-ability.type';
|
||||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo';
|
||||||
import { RecentPageDto } from './dto/recent-page.dto';
|
import { RecentPageDto } from './dto/recent-page.dto';
|
||||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||||
|
import { AddPageMembersDto } from './dto/add-page-members.dto';
|
||||||
|
import { RemovePageMemberDto } from './dto/remove-page-member.dto';
|
||||||
|
import { UpdatePageMemberRoleDto } from './dto/update-page-member-role.dto';
|
||||||
|
import { UpdatePagePermissionDto } from './dto/update-page-permission.dto';
|
||||||
|
import { GetPageMembersDto } from './dto/get-page-members.dto';
|
||||||
|
import {
|
||||||
|
PagePermissionService,
|
||||||
|
PagePermissionsResponse,
|
||||||
|
} from './services/page-member.service';
|
||||||
|
import PageAbilityFactory from '../casl/abilities/page-ability.factory';
|
||||||
|
import {
|
||||||
|
PageCaslAction,
|
||||||
|
PageCaslSubject,
|
||||||
|
} from '../casl/interfaces/page-ability.type';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('pages')
|
@Controller('pages')
|
||||||
@@ -44,6 +59,9 @@ export class PageController {
|
|||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly pageHistoryService: PageHistoryService,
|
private readonly pageHistoryService: PageHistoryService,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
private readonly pageAbility: PageAbilityFactory,
|
||||||
|
private readonly pagePermissionService: PagePermissionService,
|
||||||
|
private readonly sharedPagesRepo: SharedPagesRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -61,11 +79,21 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
const pageAbility = await this.pageAbility.createForUser(user, page.id);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
|
if (pageAbility.cannot(PageCaslAction.Read, PageCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
page.spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}*/
|
||||||
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,4 +417,162 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
return this.pageService.getPageBreadCrumbs(page.id);
|
return this.pageService.getPageBreadCrumbs(page.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('permissions/restrict')
|
||||||
|
async restrictPage(@Body() dto: PageIdDto, @AuthUser() user: User) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make sure they have access to the page, and can restrict
|
||||||
|
// And the page is not already restricted
|
||||||
|
// They can add and remove page restriction
|
||||||
|
// When a page restriction is removed, we remove the entries in page permissions table.
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pagePermissionService.restrictPage(user, page.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('permissions/add')
|
||||||
|
async addPageMembers(
|
||||||
|
@Body() dto: AddPageMembersDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pagePermissionService.addMembersToPageBatch(
|
||||||
|
dto,
|
||||||
|
user,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('permissions/remove')
|
||||||
|
async removePageMember(
|
||||||
|
@Body() dto: RemovePageMemberDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pagePermissionService.removePageMember(dto, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('permissions/update-role')
|
||||||
|
async updatePageMemberRole(
|
||||||
|
@Body() dto: UpdatePageMemberRoleDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pagePermissionService.updatePageMemberRole(dto, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('permissions/update')
|
||||||
|
async updatePagePermissions(
|
||||||
|
@Body() dto: UpdatePagePermissionDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
): Promise<PagePermissionsResponse> {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pagePermissionService.updatePagePermission(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('permissions/info')
|
||||||
|
async getPagePermissions(
|
||||||
|
@Body() dto: PageIdDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
): Promise<PagePermissionsResponse> {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pagePermissionService.getPagePermissions(dto.pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('permissions/list')
|
||||||
|
async getPageMembers(
|
||||||
|
@Body() dto: GetPageMembersDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagination: PaginationOptions = {
|
||||||
|
page: dto.page || 1,
|
||||||
|
limit: dto.limit || 20,
|
||||||
|
query: dto.query,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.pagePermissionService.getPageMembers(
|
||||||
|
dto.pageId,
|
||||||
|
workspace.id,
|
||||||
|
pagination,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('shared')
|
||||||
|
async getUserSharedPages(@AuthUser() user: User) {
|
||||||
|
return this.sharedPagesRepo.getUserSharedPages(user.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import { PageService } from './services/page.service';
|
|||||||
import { PageController } from './page.controller';
|
import { PageController } from './page.controller';
|
||||||
import { PageHistoryService } from './services/page-history.service';
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||||
|
import { PagePermissionService } from './services/page-member.service';
|
||||||
|
import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo';
|
||||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PageController],
|
controllers: [PageController],
|
||||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
providers: [
|
||||||
exports: [PageService, PageHistoryService],
|
PageService,
|
||||||
|
PageHistoryService,
|
||||||
|
TrashCleanupService,
|
||||||
|
PagePermissionService,
|
||||||
|
SharedPagesRepo,
|
||||||
|
],
|
||||||
|
exports: [PageService, PageHistoryService, PagePermissionService],
|
||||||
imports: [StorageModule],
|
imports: [StorageModule],
|
||||||
})
|
})
|
||||||
export class PageModule {}
|
export class PageModule {}
|
||||||
|
|||||||
@@ -0,0 +1,648 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
|
import {
|
||||||
|
PagePermissionRepo,
|
||||||
|
PageMemberRole,
|
||||||
|
} from '@docmost/db/repos/page/page-permission-repo.service';
|
||||||
|
import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo';
|
||||||
|
import { AddPageMembersDto } from '../dto/add-page-members.dto';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { Page, PagePermission, User } from '@docmost/db/types/entity.types';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { RemovePageMemberDto } from '../dto/remove-page-member.dto';
|
||||||
|
import { UpdatePageMemberRoleDto } from '../dto/update-page-member-role.dto';
|
||||||
|
import { UpdatePagePermissionDto } from '../dto/update-page-permission.dto';
|
||||||
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
import { executeTx } from '@docmost/db/utils';
|
||||||
|
|
||||||
|
export interface IPagePermission {
|
||||||
|
id: string;
|
||||||
|
cascade: boolean;
|
||||||
|
member: {
|
||||||
|
id: string;
|
||||||
|
type: 'user' | 'group' | 'public';
|
||||||
|
email?: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
workspaceRole?: string;
|
||||||
|
name?: string;
|
||||||
|
memberCount?: number;
|
||||||
|
};
|
||||||
|
membershipRole: {
|
||||||
|
id: string;
|
||||||
|
level: string;
|
||||||
|
source: 'direct' | 'inherited';
|
||||||
|
};
|
||||||
|
grantedBy: {
|
||||||
|
id: string;
|
||||||
|
type: 'page' | 'space';
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
parentId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagePermissionsResponse {
|
||||||
|
page: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
hasCustomPermissions: boolean;
|
||||||
|
inheritPermissions: boolean;
|
||||||
|
permissions: IPagePermission[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PagePermissionService {
|
||||||
|
constructor(
|
||||||
|
private pageMemberRepo: PagePermissionRepo,
|
||||||
|
private pageRepo: PageRepo,
|
||||||
|
private sharedPagesRepo: SharedPagesRepo,
|
||||||
|
private userRepo: UserRepo,
|
||||||
|
private groupRepo: GroupRepo,
|
||||||
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async addUserToPage(
|
||||||
|
userId: string,
|
||||||
|
pageId: string,
|
||||||
|
role: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.pageMemberRepo.insertPageMember(
|
||||||
|
{
|
||||||
|
userId: userId,
|
||||||
|
pageId: pageId,
|
||||||
|
role: role,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addGroupToPage(
|
||||||
|
groupId: string,
|
||||||
|
pageId: string,
|
||||||
|
role: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.pageMemberRepo.insertPageMember(
|
||||||
|
{
|
||||||
|
groupId: groupId,
|
||||||
|
pageId: pageId,
|
||||||
|
role: role,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPageMembers(
|
||||||
|
pageId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
pagination: PaginationOptions,
|
||||||
|
) {
|
||||||
|
const page = await this.pageRepo.findById(pageId);
|
||||||
|
// const page = await this.pageRepo.findById(pageId, { workspaceId });
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await this.pageMemberRepo.getPageMembersPaginated(
|
||||||
|
pageId,
|
||||||
|
pagination,
|
||||||
|
);
|
||||||
|
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restrictPage(authUser: User, pageId: string) {
|
||||||
|
// to add custom permissions to a page,
|
||||||
|
// we have to restrict the page first.
|
||||||
|
// the user is here because they can restrict this page
|
||||||
|
// TODO: make sure page is not in trash
|
||||||
|
// Not sure if normal users can see restricted pages in trash.
|
||||||
|
await this.db
|
||||||
|
.updateTable('pages')
|
||||||
|
.set({
|
||||||
|
isRestricted: true,
|
||||||
|
restrictedById: authUser.id,
|
||||||
|
})
|
||||||
|
.where('id', '=', pageId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMembersToPageBatch(
|
||||||
|
dto: AddPageMembersDto,
|
||||||
|
authUser: User,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
//const page = await this.pageRepo.findById(dto.pageId, { workspaceId });
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role
|
||||||
|
if (!Object.values(PageMemberRole).includes(dto.role as PageMemberRole)) {
|
||||||
|
throw new BadRequestException(`Invalid role: ${dto.role}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable custom permissions if adding first member
|
||||||
|
/*if (!page.hasCustomPermissions) {
|
||||||
|
await this.pageRepo.update(dto.pageId, {
|
||||||
|
hasCustomPermissions: true,
|
||||||
|
inheritPermissions: false,
|
||||||
|
});
|
||||||
|
}*/
|
||||||
|
|
||||||
|
// Make sure we have valid workspace users
|
||||||
|
const validUsersQuery = this.db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'name'])
|
||||||
|
.where('users.id', 'in', dto.userIds)
|
||||||
|
.where('users.workspaceId', '=', workspaceId)
|
||||||
|
.where(({ not, exists, selectFrom }) =>
|
||||||
|
not(
|
||||||
|
exists(
|
||||||
|
selectFrom('pagePermissions')
|
||||||
|
.select('id')
|
||||||
|
.whereRef('pagePermissions.userId', '=', 'users.id')
|
||||||
|
.where('pagePermissions.pageId', '=', dto.pageId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const validGroupsQuery = this.db
|
||||||
|
.selectFrom('groups')
|
||||||
|
.select(['id', 'name'])
|
||||||
|
.where('groups.id', 'in', dto.groupIds)
|
||||||
|
.where('groups.workspaceId', '=', workspaceId)
|
||||||
|
.where(({ not, exists, selectFrom }) =>
|
||||||
|
not(
|
||||||
|
exists(
|
||||||
|
selectFrom('pagePermissions')
|
||||||
|
.select('id')
|
||||||
|
.whereRef('pagePermissions.groupId', '=', 'groups.id')
|
||||||
|
.where('pagePermissions.pageId', '=', dto.pageId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let validUsers = [],
|
||||||
|
validGroups = [];
|
||||||
|
if (dto.userIds && dto.userIds.length > 0) {
|
||||||
|
validUsers = await validUsersQuery.execute();
|
||||||
|
}
|
||||||
|
if (dto.groupIds && dto.groupIds.length > 0) {
|
||||||
|
validGroups = await validGroupsQuery.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersToAdd = [];
|
||||||
|
for (const user of validUsers) {
|
||||||
|
usersToAdd.push({
|
||||||
|
pageId: dto.pageId,
|
||||||
|
userId: user.id,
|
||||||
|
role: dto.role,
|
||||||
|
addedById: authUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track orphaned page access if user doesn't have parent access
|
||||||
|
if (page.parentPageId && dto.role !== PageMemberRole.NONE) {
|
||||||
|
const hasParentAccess = await this.checkParentAccess(
|
||||||
|
user.id,
|
||||||
|
page.parentPageId,
|
||||||
|
);
|
||||||
|
if (!hasParentAccess) {
|
||||||
|
await this.sharedPagesRepo.addSharedPage(user.id, dto.pageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupsToAdd = [];
|
||||||
|
for (const group of validGroups) {
|
||||||
|
groupsToAdd.push({
|
||||||
|
pageId: dto.pageId,
|
||||||
|
groupId: group.id,
|
||||||
|
role: dto.role,
|
||||||
|
addedById: authUser.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const membersToAdd = [...usersToAdd, ...groupsToAdd];
|
||||||
|
if (membersToAdd.length > 0) {
|
||||||
|
await this.db
|
||||||
|
.insertInto('pagePermissions')
|
||||||
|
.values(membersToAdd)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to child pages if requested
|
||||||
|
if (dto.cascade) {
|
||||||
|
await this.cascadeToChildren(dto.pageId, membersToAdd);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof NotFoundException ||
|
||||||
|
error instanceof BadRequestException
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Failed to add members to page. Please try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePageMember(
|
||||||
|
dto: RemovePageMemberDto,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const member = await this.db
|
||||||
|
.selectFrom('pagePermissions')
|
||||||
|
.innerJoin('pages', 'pages.id', 'pagePermissions.pageId')
|
||||||
|
.select(['pagePermissions.id', 'pagePermissions.userId'])
|
||||||
|
.where('pagePermissions.id', '=', dto.memberId)
|
||||||
|
.where('pagePermissions.pageId', '=', dto.pageId)
|
||||||
|
.where('pages.workspaceId', '=', workspaceId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new NotFoundException('Page member not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the last admin
|
||||||
|
const adminCount = await this.pageMemberRepo.roleCountByPageId(
|
||||||
|
PageMemberRole.ADMIN,
|
||||||
|
dto.pageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (adminCount === 1) {
|
||||||
|
const memberToRemove = await this.pageMemberRepo.getPageMemberByTypeId(
|
||||||
|
dto.pageId,
|
||||||
|
{ userId: member.userId },
|
||||||
|
);
|
||||||
|
if (memberToRemove?.role === PageMemberRole.ADMIN) {
|
||||||
|
throw new BadRequestException('Cannot remove the last admin from page');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pageMemberRepo.removePageMemberById(dto.memberId, dto.pageId);
|
||||||
|
|
||||||
|
// Remove from shared pages if it was tracked
|
||||||
|
if (member.userId) {
|
||||||
|
await this.sharedPagesRepo.removeSharedPage(member.userId, dto.pageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePageMemberRole(
|
||||||
|
dto: UpdatePageMemberRoleDto,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const member = await this.db
|
||||||
|
.selectFrom('pagePermissions')
|
||||||
|
.innerJoin('pages', 'pages.id', 'pagePermissions.pageId')
|
||||||
|
.select(['pagePermissions.id', 'pagePermissions.role'])
|
||||||
|
.where('pagePermissions.id', '=', dto.memberId)
|
||||||
|
.where('pagePermissions.pageId', '=', dto.pageId)
|
||||||
|
.where('pages.workspaceId', '=', workspaceId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new NotFoundException('Page member not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
member.role === PageMemberRole.ADMIN &&
|
||||||
|
dto.role !== PageMemberRole.ADMIN
|
||||||
|
) {
|
||||||
|
const adminCount = await this.pageMemberRepo.roleCountByPageId(
|
||||||
|
PageMemberRole.ADMIN,
|
||||||
|
dto.pageId,
|
||||||
|
);
|
||||||
|
if (adminCount === 1) {
|
||||||
|
throw new BadRequestException('Cannot change role of the last admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pageMemberRepo.updatePageMember(
|
||||||
|
{ role: dto.role },
|
||||||
|
dto.memberId,
|
||||||
|
dto.pageId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePagePermission(
|
||||||
|
dto: UpdatePagePermissionDto,
|
||||||
|
): Promise<PagePermissionsResponse> {
|
||||||
|
const { pageId, userId, groupId, role, cascade } = dto;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate inputs
|
||||||
|
if (!userId && !groupId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Either userId or groupId must be provided',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId && groupId) {
|
||||||
|
throw new BadRequestException('Cannot provide both userId and groupId');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.values(PageMemberRole).includes(role as PageMemberRole)) {
|
||||||
|
throw new BadRequestException(`Invalid role: ${role}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeTx(this.db, async (trx) => {
|
||||||
|
// Update the role
|
||||||
|
if (userId) {
|
||||||
|
await this.pageMemberRepo.upsertPageMember(
|
||||||
|
{
|
||||||
|
pageId,
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
} else if (groupId) {
|
||||||
|
await this.pageMemberRepo.upsertPageMember(
|
||||||
|
{
|
||||||
|
pageId,
|
||||||
|
groupId,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark page as having custom permissions
|
||||||
|
/* await this.pageRepo.update(
|
||||||
|
pageId,
|
||||||
|
{
|
||||||
|
hasCustomPermissions: true,
|
||||||
|
inheritPermissions: false,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);*/
|
||||||
|
|
||||||
|
// Cascade to children if requested
|
||||||
|
if (cascade) {
|
||||||
|
const descendants = await this.pageRepo.getAllDescendants(
|
||||||
|
pageId,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
for (const childId of descendants) {
|
||||||
|
if (userId) {
|
||||||
|
await this.pageMemberRepo.upsertPageMember(
|
||||||
|
{
|
||||||
|
pageId: childId,
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
} else if (groupId) {
|
||||||
|
await this.pageMemberRepo.upsertPageMember(
|
||||||
|
{
|
||||||
|
pageId: childId,
|
||||||
|
groupId,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return comprehensive permission data
|
||||||
|
return this.getPagePermissions(pageId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BadRequestException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Failed to update page permissions. Please try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPagePermissions(pageId: string): Promise<PagePermissionsResponse> {
|
||||||
|
const page = await this.pageRepo.findById(pageId, { includeSpace: true });
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions: IPagePermission[] = [];
|
||||||
|
|
||||||
|
// 1. Get direct page members
|
||||||
|
const directMembers = await this.pageMemberRepo.getPageMembers(pageId);
|
||||||
|
|
||||||
|
// Batch fetch all users and groups
|
||||||
|
const userIds = directMembers.filter((m) => m.userId).map((m) => m.userId);
|
||||||
|
const groupIds = directMembers
|
||||||
|
.filter((m) => m.groupId)
|
||||||
|
.map((m) => m.groupId);
|
||||||
|
|
||||||
|
const [users, groups] = await Promise.all([
|
||||||
|
userIds.length > 0
|
||||||
|
? this.db
|
||||||
|
.selectFrom('users')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', 'in', userIds)
|
||||||
|
.execute()
|
||||||
|
: Promise.resolve([]),
|
||||||
|
groupIds.length > 0
|
||||||
|
? this.db
|
||||||
|
.selectFrom('groups')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', 'in', groupIds)
|
||||||
|
.execute()
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userMap = new Map(users.map((u) => [u.id, u] as const));
|
||||||
|
const groupMap = new Map(groups.map((g) => [g.id, g] as const));
|
||||||
|
|
||||||
|
// Build permissions with batch-fetched data
|
||||||
|
for (const member of directMembers) {
|
||||||
|
let memberData: any = null;
|
||||||
|
|
||||||
|
if (member.userId) {
|
||||||
|
const user = userMap.get(member.userId);
|
||||||
|
if (user) {
|
||||||
|
memberData = {
|
||||||
|
id: user.id,
|
||||||
|
type: 'user' as const,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.name,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
workspaceRole: user.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (member.groupId) {
|
||||||
|
const group = groupMap.get(member.groupId);
|
||||||
|
if (group) {
|
||||||
|
memberData = {
|
||||||
|
id: group.id,
|
||||||
|
type: 'group' as const,
|
||||||
|
name: group.name,
|
||||||
|
memberCount: await this.db
|
||||||
|
.selectFrom('groupUsers')
|
||||||
|
.select((eb) => eb.fn.count('userId').as('count'))
|
||||||
|
.where('groupId', '=', group.id)
|
||||||
|
.executeTakeFirst()
|
||||||
|
.then((result) => Number(result?.count || 0)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memberData) {
|
||||||
|
permissions.push({
|
||||||
|
id: member.id,
|
||||||
|
cascade: true, // Page permissions cascade by default
|
||||||
|
member: memberData,
|
||||||
|
membershipRole: {
|
||||||
|
id: member.id,
|
||||||
|
level: member.role,
|
||||||
|
source: 'direct',
|
||||||
|
},
|
||||||
|
grantedBy: {
|
||||||
|
id: pageId,
|
||||||
|
type: 'page',
|
||||||
|
title: page.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get inherited space members (if page inherits)
|
||||||
|
if (page) {
|
||||||
|
//if (page.inheritPermissions || !page.hasCustomPermissions) {
|
||||||
|
const spaceMembers = await this.spaceMemberRepo.getSpaceMembersPaginated(
|
||||||
|
page.spaceId,
|
||||||
|
{ page: 1, limit: 100 },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const spaceMember of spaceMembers.items as any[]) {
|
||||||
|
// Skip if user has direct page permission
|
||||||
|
const hasDirect = directMembers.some(
|
||||||
|
(dm) =>
|
||||||
|
(dm.userId === spaceMember.id && spaceMember.type === 'user') ||
|
||||||
|
(dm.groupId === spaceMember.id && spaceMember.type === 'group'),
|
||||||
|
);
|
||||||
|
if (!hasDirect) {
|
||||||
|
permissions.push({
|
||||||
|
id: `space-${spaceMember.id}`,
|
||||||
|
cascade: false, // Space permissions don't cascade to page children
|
||||||
|
member: {
|
||||||
|
id: spaceMember.id,
|
||||||
|
type: spaceMember.type as 'user' | 'group',
|
||||||
|
email: spaceMember.email,
|
||||||
|
displayName: spaceMember.name,
|
||||||
|
avatarUrl: spaceMember.avatarUrl,
|
||||||
|
name: spaceMember.name,
|
||||||
|
memberCount: Number(spaceMember.memberCount || 0),
|
||||||
|
},
|
||||||
|
membershipRole: {
|
||||||
|
id: `space-role-${spaceMember.id}`,
|
||||||
|
level: spaceMember.role,
|
||||||
|
source: 'inherited',
|
||||||
|
},
|
||||||
|
grantedBy: {
|
||||||
|
id: page.spaceId,
|
||||||
|
type: 'space',
|
||||||
|
name: (page as any).space?.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: {
|
||||||
|
id: page.id,
|
||||||
|
title: page.title,
|
||||||
|
hasCustomPermissions: true,
|
||||||
|
inheritPermissions: false,
|
||||||
|
permissions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkParentAccess(
|
||||||
|
userId: string,
|
||||||
|
parentPageId: string | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!parentPageId) return true; // Root pages always accessible
|
||||||
|
|
||||||
|
const parentAccess = await this.pageMemberRepo.resolveUserPageAccess(
|
||||||
|
userId,
|
||||||
|
parentPageId,
|
||||||
|
);
|
||||||
|
return parentAccess !== null && parentAccess !== PageMemberRole.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cascadeToChildren(
|
||||||
|
pageId: string,
|
||||||
|
membersToAdd: any[],
|
||||||
|
): Promise<void> {
|
||||||
|
const descendants = await this.pageRepo.getAllDescendants(pageId);
|
||||||
|
if (descendants.length === 0) return;
|
||||||
|
|
||||||
|
// Separate user and group members for proper conflict handling
|
||||||
|
const userMembers = membersToAdd.filter((m) => m.userId);
|
||||||
|
const groupMembers = membersToAdd.filter((m) => m.groupId);
|
||||||
|
|
||||||
|
for (const childId of descendants) {
|
||||||
|
// Handle user members with proper conflict resolution
|
||||||
|
if (userMembers.length > 0) {
|
||||||
|
const childUserMembers = userMembers.map((m) => ({
|
||||||
|
...m,
|
||||||
|
pageId: childId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.insertInto('pagePermissions')
|
||||||
|
.values(childUserMembers)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc.columns(['pageId', 'userId']).doUpdateSet({
|
||||||
|
role: (eb) => eb.ref('excluded.role'),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle group members separately
|
||||||
|
if (groupMembers.length > 0) {
|
||||||
|
const childGroupMembers = groupMembers.map((m) => ({
|
||||||
|
...m,
|
||||||
|
pageId: childId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.insertInto('pagePermissions')
|
||||||
|
.values(childGroupMembers)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc.columns(['pageId', 'groupId']).doUpdateSet({
|
||||||
|
role: (eb) => eb.ref('excluded.role'),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,13 +74,16 @@ export class SearchService {
|
|||||||
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
|
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
|
||||||
} else if (opts.userId && !searchParams.spaceId) {
|
} else if (opts.userId && !searchParams.spaceId) {
|
||||||
// only search spaces the user is a member of
|
// only search spaces the user is a member of
|
||||||
queryResults = queryResults
|
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
|
||||||
.where(
|
opts.userId,
|
||||||
'spaceId',
|
);
|
||||||
'in',
|
if (userSpaceIds.length > 0) {
|
||||||
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
|
queryResults = queryResults
|
||||||
)
|
.where('spaceId', 'in', userSpaceIds)
|
||||||
.where('workspaceId', '=', opts.workspaceId);
|
.where('workspaceId', '=', opts.workspaceId);
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
|
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
|
||||||
// search in shares
|
// search in shares
|
||||||
const shareId = searchParams.shareId;
|
const shareId = searchParams.shareId;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { validate as isValidUUID } from 'uuid';
|
|||||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
import { Workspace } from '@docmost/db/types/entity.types';
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
import { htmlEscape } from '../../common/helpers/html-escaper';
|
|
||||||
|
|
||||||
@Controller('share')
|
@Controller('share')
|
||||||
export class ShareSeoController {
|
export class ShareSeoController {
|
||||||
@@ -69,7 +68,7 @@ export class ShareSeoController {
|
|||||||
return this.sendIndex(indexFilePath, res);
|
return this.sendIndex(indexFilePath, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled');
|
const rawTitle = share.sharedPage.title ?? 'untitled';
|
||||||
const metaTitle =
|
const metaTitle =
|
||||||
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle;
|
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle;
|
||||||
|
|
||||||
|
|||||||
@@ -123,82 +123,80 @@ export class ShareService {
|
|||||||
.withRecursive('page_hierarchy', (cte) =>
|
.withRecursive('page_hierarchy', (cte) =>
|
||||||
cte
|
cte
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.leftJoin('shares', 'shares.pageId', 'pages.id')
|
|
||||||
.select([
|
.select([
|
||||||
'pages.id',
|
'id',
|
||||||
'pages.slugId',
|
'slugId',
|
||||||
'pages.title',
|
'pages.title',
|
||||||
'pages.icon',
|
'pages.icon',
|
||||||
'pages.parentPageId',
|
'parentPageId',
|
||||||
sql`0`.as('level'),
|
sql`0`.as('level'),
|
||||||
'shares.id as shareId',
|
|
||||||
'shares.key as shareKey',
|
|
||||||
'shares.includeSubPages',
|
|
||||||
'shares.searchIndexing',
|
|
||||||
'shares.creatorId',
|
|
||||||
'shares.spaceId',
|
|
||||||
'shares.workspaceId',
|
|
||||||
'shares.createdAt',
|
|
||||||
])
|
])
|
||||||
.where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
|
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
|
||||||
.where('pages.deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.unionAll(
|
.unionAll((union) =>
|
||||||
(union) =>
|
union
|
||||||
union
|
.selectFrom('pages as p')
|
||||||
.selectFrom('pages as p')
|
.select([
|
||||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
'p.id',
|
||||||
.leftJoin('shares as s', 's.pageId', 'p.id')
|
'p.slugId',
|
||||||
.select([
|
'p.title',
|
||||||
'p.id',
|
'p.icon',
|
||||||
'p.slugId',
|
'p.parentPageId',
|
||||||
'p.title',
|
// Increase the level by 1 for each ancestor.
|
||||||
'p.icon',
|
sql`ph.level + 1`.as('level'),
|
||||||
'p.parentPageId',
|
])
|
||||||
sql`ph.level + 1`.as('level'),
|
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
||||||
's.id as shareId',
|
.where('p.deletedAt', 'is', null),
|
||||||
's.key as shareKey',
|
|
||||||
's.includeSubPages',
|
|
||||||
's.searchIndexing',
|
|
||||||
's.creatorId',
|
|
||||||
's.spaceId',
|
|
||||||
's.workspaceId',
|
|
||||||
's.createdAt',
|
|
||||||
])
|
|
||||||
.where('p.deletedAt', 'is', null)
|
|
||||||
.where(sql`ph.share_id`, 'is', null) // stop if share found
|
|
||||||
.where(sql`ph.level`, '<', sql`25`), // prevent loop
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.selectFrom('page_hierarchy')
|
.selectFrom('page_hierarchy')
|
||||||
.selectAll()
|
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id')
|
||||||
.where('shareId', 'is not', null)
|
.select([
|
||||||
.limit(1)
|
'page_hierarchy.id as sharedPageId',
|
||||||
|
'page_hierarchy.slugId as sharedPageSlugId',
|
||||||
|
'page_hierarchy.title as sharedPageTitle',
|
||||||
|
'page_hierarchy.icon as sharedPageIcon',
|
||||||
|
'page_hierarchy.level as level',
|
||||||
|
'shares.id',
|
||||||
|
'shares.key',
|
||||||
|
'shares.pageId',
|
||||||
|
'shares.includeSubPages',
|
||||||
|
'shares.searchIndexing',
|
||||||
|
'shares.creatorId',
|
||||||
|
'shares.spaceId',
|
||||||
|
'shares.workspaceId',
|
||||||
|
'shares.createdAt',
|
||||||
|
'shares.updatedAt',
|
||||||
|
])
|
||||||
|
.where('shares.id', 'is not', null)
|
||||||
|
.orderBy('page_hierarchy.level', 'asc')
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!share || share.workspaceId !== workspaceId) {
|
if (!share || share.workspaceId != workspaceId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((share.level as number) > 0 && !share.includeSubPages) {
|
if (share.level === 1 && !share.includeSubPages) {
|
||||||
|
// we can only show a page if its shared ancestor permits it
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: share.shareId,
|
id: share.id,
|
||||||
key: share.shareKey,
|
key: share.key,
|
||||||
includeSubPages: share.includeSubPages,
|
includeSubPages: share.includeSubPages,
|
||||||
searchIndexing: share.searchIndexing,
|
searchIndexing: share.searchIndexing,
|
||||||
pageId: share.id,
|
pageId: share.pageId,
|
||||||
creatorId: share.creatorId,
|
creatorId: share.creatorId,
|
||||||
spaceId: share.spaceId,
|
spaceId: share.spaceId,
|
||||||
workspaceId: share.workspaceId,
|
workspaceId: share.workspaceId,
|
||||||
createdAt: share.createdAt,
|
createdAt: share.createdAt,
|
||||||
level: share.level,
|
level: share.level,
|
||||||
sharedPage: {
|
sharedPage: {
|
||||||
id: share.id,
|
id: share.sharedPageId,
|
||||||
slugId: share.slugId,
|
slugId: share.sharedPageSlugId,
|
||||||
title: share.title,
|
title: share.sharedPageTitle,
|
||||||
icon: share.icon,
|
icon: share.sharedPageIcon,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import {Transform, TransformFnParams} from "class-transformer";
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
|
||||||
export class CreateSpaceDto {
|
export class CreateSpaceDto {
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(100)
|
@MaxLength(50)
|
||||||
@IsString()
|
@IsString()
|
||||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||||
name: string;
|
name: string;
|
||||||
@@ -19,7 +19,7 @@ export class CreateSpaceDto {
|
|||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(100)
|
@MaxLength(50)
|
||||||
@IsAlphanumeric()
|
@IsAlphanumeric()
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ export class UserService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isPasswordMatch) {
|
if (!isPasswordMatch) {
|
||||||
throw new BadRequestException('You must provide the correct password to change your email');
|
throw new BadRequestException(
|
||||||
|
'You must provide the correct password to change your email',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await this.userRepo.findByEmail(updateUserDto.email, workspace.id)) {
|
if (await this.userRepo.findByEmail(updateUserDto.email, workspace.id)) {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
|
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
|
||||||
import { EnvironmentService } from '../integrations/environment/environment.service';
|
import { EnvironmentService } from '../integrations/environment/environment.service';
|
||||||
import { CamelCasePlugin, LogEvent, sql } from 'kysely';
|
import { CamelCasePlugin, LogEvent, PostgresDialect, sql } from 'kysely';
|
||||||
|
import { Pool, types } from 'pg';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
@@ -25,9 +26,10 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
|||||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission-repo.service';
|
||||||
import * as postgres from 'postgres';
|
|
||||||
import { normalizePostgresUrl } from '../common/helpers';
|
// https://github.com/brianc/node-postgres/issues/811
|
||||||
|
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -36,30 +38,26 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
imports: [],
|
imports: [],
|
||||||
inject: [EnvironmentService],
|
inject: [EnvironmentService],
|
||||||
useFactory: (environmentService: EnvironmentService) => ({
|
useFactory: (environmentService: EnvironmentService) => ({
|
||||||
dialect: new PostgresJSDialect({
|
dialect: new PostgresDialect({
|
||||||
postgres: postgres(
|
pool: new Pool({
|
||||||
normalizePostgresUrl(environmentService.getDatabaseURL()),
|
connectionString: environmentService.getDatabaseURL(),
|
||||||
{
|
max: environmentService.getDatabaseMaxPool(),
|
||||||
max: environmentService.getDatabaseMaxPool(),
|
}).on('error', (err) => {
|
||||||
onnotice: () => {},
|
console.error('Database error:', err.message);
|
||||||
types: {
|
}),
|
||||||
bigint: {
|
|
||||||
to: 20,
|
|
||||||
from: [20, 1700],
|
|
||||||
serialize: (value: number) => value.toString(),
|
|
||||||
parse: (value: string) => Number.parseInt(value),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
plugins: [new CamelCasePlugin()],
|
plugins: [new CamelCasePlugin()],
|
||||||
log: (event: LogEvent) => {
|
log: (event: LogEvent) => {
|
||||||
if (environmentService.getNodeEnv() !== 'development') return;
|
if (environmentService.getNodeEnv() !== 'development') return;
|
||||||
const logger = new Logger(DatabaseModule.name);
|
const logger = new Logger(DatabaseModule.name);
|
||||||
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
|
if (event.level) {
|
||||||
logger.debug(event.query.sql);
|
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
|
||||||
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
|
logger.debug(event.query.sql);
|
||||||
|
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
|
||||||
|
//if (event.query.parameters.length > 0) {
|
||||||
|
// logger.debug('parameters: ' + event.query.parameters);
|
||||||
|
//}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -81,6 +79,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
ShareRepo,
|
ShareRepo,
|
||||||
PageListener,
|
PageListener,
|
||||||
|
PagePermissionRepo,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
WorkspaceRepo,
|
WorkspaceRepo,
|
||||||
@@ -96,6 +95,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
ShareRepo,
|
ShareRepo,
|
||||||
|
PagePermissionRepo,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DatabaseModule
|
export class DatabaseModule
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import { Kysely, Migrator, FileMigrationProvider } from 'kysely';
|
import pg from 'pg';
|
||||||
|
import {
|
||||||
|
Kysely,
|
||||||
|
Migrator,
|
||||||
|
PostgresDialect,
|
||||||
|
FileMigrationProvider,
|
||||||
|
} from 'kysely';
|
||||||
import { run } from 'kysely-migration-cli';
|
import { run } from 'kysely-migration-cli';
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import { envPath, normalizePostgresUrl } from '../common/helpers';
|
import { envPath } from '../common/helpers/utils';
|
||||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
|
|
||||||
dotenv.config({ path: envPath });
|
dotenv.config({ path: envPath });
|
||||||
|
|
||||||
const migrationFolder = path.join(__dirname, './migrations');
|
const migrationFolder = path.join(__dirname, './migrations');
|
||||||
|
|
||||||
const db = new Kysely<any>({
|
const db = new Kysely<any>({
|
||||||
dialect: new PostgresJSDialect({
|
dialect: new PostgresDialect({
|
||||||
postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL)),
|
pool: new pg.Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
}) as any,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { type Kysely, sql } from 'kysely';
|
|||||||
export async function up(db: Kysely<any>): Promise<void> {
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
await db.schema
|
await db.schema
|
||||||
.alterTable('pages')
|
.alterTable('pages')
|
||||||
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo("{}"))
|
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}'))
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('page_permissions')
|
||||||
|
.addColumn('id', 'uuid', (col) =>
|
||||||
|
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||||
|
)
|
||||||
|
.addColumn('user_id', 'uuid', (col) =>
|
||||||
|
col.references('users.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('group_id', 'uuid', (col) =>
|
||||||
|
col.references('groups.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('page_id', 'uuid', (col) =>
|
||||||
|
col.notNull().references('pages.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('role', 'varchar', (col) => col.notNull())
|
||||||
|
.addColumn('cascade', 'boolean', (col) => col.defaultTo(true).notNull()) // children can inherit
|
||||||
|
.addColumn('added_by_id', 'uuid', (col) =>
|
||||||
|
col.references('users.id').onDelete('set null'),
|
||||||
|
)
|
||||||
|
.addColumn('created_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.addColumn('deleted_at', 'timestamptz')
|
||||||
|
.addUniqueConstraint('unique_page_user', ['page_id', 'user_id'])
|
||||||
|
.addUniqueConstraint('unique_page_group', ['page_id', 'group_id'])
|
||||||
|
.addCheckConstraint(
|
||||||
|
'allow_either_user_id_or_group_id_check',
|
||||||
|
sql`(user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL)`,
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.alterTable('pages')
|
||||||
|
.addColumn('is_restricted', 'boolean', (col) =>
|
||||||
|
col.defaultTo(false).notNull(),
|
||||||
|
)
|
||||||
|
.addColumn('restricted_by_id', 'uuid', (col) =>
|
||||||
|
col.references('users.id').onDelete('set null'),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Add indexes for performance
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_page_permissions_page_id')
|
||||||
|
.on('page_permissions')
|
||||||
|
.column('page_id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_page_permissions_user_id')
|
||||||
|
.on('page_permissions')
|
||||||
|
.column('user_id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_page_permissions_group_id')
|
||||||
|
.on('page_permissions')
|
||||||
|
.column('group_id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Create user_shared_pages table for tracking orphaned page access
|
||||||
|
await db.schema
|
||||||
|
.createTable('user_shared_pages')
|
||||||
|
.addColumn('user_id', 'uuid', (col) =>
|
||||||
|
col.notNull().references('users.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('page_id', 'uuid', (col) =>
|
||||||
|
col.notNull().references('pages.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('shared_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.addPrimaryKeyConstraint('user_shared_pages_pkey', ['user_id', 'page_id'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_user_shared_pages_user_id')
|
||||||
|
.on('user_shared_pages')
|
||||||
|
.column('user_id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_user_shared_pages_shared_at')
|
||||||
|
.on('user_shared_pages')
|
||||||
|
.column('shared_at')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.alterTable('pages').dropColumn('is_restricted').execute();
|
||||||
|
await db.schema.alterTable('pages').dropColumn('restricted_by_id').execute();
|
||||||
|
|
||||||
|
await db.schema.dropTable('user_shared_pages').execute();
|
||||||
|
|
||||||
|
await db.schema.dropTable('page_permissions').execute();
|
||||||
|
}
|
||||||
@@ -23,9 +23,9 @@ export class PaginationOptions {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
query: string;
|
query?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
adminView: boolean;
|
adminView?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ export class CommentRepo {
|
|||||||
return Number(result?.count) > 0;
|
return Number(result?.count) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasChildrenFromOtherUsers(commentId: string, userId: string): Promise<boolean> {
|
async hasChildrenFromOtherUsers(
|
||||||
|
commentId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.selectFrom('comments')
|
.selectFrom('comments')
|
||||||
.select((eb) => eb.fn.count('id').as('count'))
|
.select((eb) => eb.fn.count('id').as('count'))
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ export class GroupUserRepo {
|
|||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
eb(sql`f_unaccent(users.name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`),
|
eb(
|
||||||
|
sql`f_unaccent(users.name)`,
|
||||||
|
'ilike',
|
||||||
|
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,11 @@ export class GroupRepo {
|
|||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or(
|
eb(
|
||||||
|
sql`f_unaccent(name)`,
|
||||||
|
'ilike',
|
||||||
|
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||||
|
).or(
|
||||||
sql`f_unaccent(description)`,
|
sql`f_unaccent(description)`,
|
||||||
'ilike',
|
'ilike',
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user