mirror of
https://github.com/docmost/docmost.git
synced 2026-05-08 07:13:06 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 404e6c0b2f | |||
| 900e367677 | |||
| ace00a0b0a |
+2
-3
@@ -1,6 +1,5 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
dist
|
||||
/data
|
||||
.env*
|
||||
.nx
|
||||
data
|
||||
|
||||
+1
-7
@@ -46,10 +46,4 @@ DRAWIO_URL=
|
||||
DISABLE_TELEMETRY=false
|
||||
|
||||
# Enable debug logging in production (default: false)
|
||||
DEBUG_MODE=false
|
||||
|
||||
# Log database queries
|
||||
DEBUG_DB=false
|
||||
|
||||
# Log http requests
|
||||
LOG_HTTP=false
|
||||
DEBUG_MODE=false
|
||||
+3
-3
@@ -1,14 +1,13 @@
|
||||
FROM node:22-slim AS base
|
||||
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
||||
|
||||
RUN npm install -g pnpm@10.4.0
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm install -g pnpm@10.4.0
|
||||
RUN pnpm install --frozen-lockfile
|
||||
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 --from=builder /app/package.json /app/package.json
|
||||
COPY --from=builder /app/pnpm*.yaml /app/
|
||||
COPY --from=builder /app/.npmrc /app/.npmrc
|
||||
|
||||
# Copy patches
|
||||
COPY --from=builder /app/patches /app/patches
|
||||
|
||||
RUN npm install -g pnpm@10.4.0
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
+32
-29
@@ -10,49 +10,52 @@
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.2",
|
||||
"@casl/react": "^4.0.0",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "0.18.0-c158187",
|
||||
"@mantine/core": "^8.3.12",
|
||||
"@mantine/dates": "^8.3.12",
|
||||
"@mantine/form": "^8.3.12",
|
||||
"@mantine/hooks": "^8.3.12",
|
||||
"@mantine/modals": "^8.3.12",
|
||||
"@mantine/notifications": "^8.3.12",
|
||||
"@mantine/spotlight": "^8.3.12",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tanstack/react-query": "^5.90.17",
|
||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||
"@mantine/core": "^8.1.3",
|
||||
"@mantine/dates": "^8.3.2",
|
||||
"@mantine/form": "^8.1.3",
|
||||
"@mantine/hooks": "^8.1.3",
|
||||
"@mantine/modals": "^8.1.3",
|
||||
"@mantine/notifications": "^8.1.3",
|
||||
"@mantine/spotlight": "^8.1.3",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"@tiptap/extension-character-count": "^2.10.3",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-http-backend": "^2.7.3",
|
||||
"jotai": "^2.16.2",
|
||||
"i18next": "^23.14.0",
|
||||
"i18next-http-backend": "^2.6.1",
|
||||
"jotai": "^2.12.5",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "0.16.27",
|
||||
"katex": "0.16.22",
|
||||
"lowlight": "^3.3.0",
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"mermaid": "^11.11.0",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"react": "^19.2.3",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.17",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-drawio": "^1.0.7",
|
||||
"react-error-boundary": "^6.1.0",
|
||||
"react-clear-modal": "^2.0.15",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^1.0.1",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"semver": "^7.7.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"react-router-dom": "^7.0.1",
|
||||
"semver": "^7.7.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
@@ -63,13 +66,13 @@
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "22.19.1",
|
||||
"@types/react": "^19.2.9",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.13.0",
|
||||
"optics-ts": "^2.4.1",
|
||||
"postcss": "^8.4.49",
|
||||
|
||||
@@ -328,8 +328,6 @@
|
||||
"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.",
|
||||
"Uploading {{name}}": "Uploading {{name}}",
|
||||
"Uploading file": "Uploading file",
|
||||
"Table": "Table",
|
||||
"Insert a table.": "Insert a table.",
|
||||
"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 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.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。",
|
||||
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになれます",
|
||||
"Can create and edit pages in space.": "スペース内のページを作成・編集できます",
|
||||
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになることができます",
|
||||
"Can create and edit pages in space.": "スペース内のページを作成および編集できます。",
|
||||
"Can edit": "編集可能",
|
||||
"Can manage workspace": "ワークスペースを管理できます",
|
||||
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
|
||||
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが、削除はできません",
|
||||
"Can view": "閲覧可能",
|
||||
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
|
||||
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが、編集はできません。",
|
||||
"Cancel": "キャンセル",
|
||||
"Change email": "メールアドレスの変更",
|
||||
"Change password": "パスワードの変更",
|
||||
"Change photo": "画像の変更",
|
||||
"Choose a role": "ロールを選んでください",
|
||||
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
|
||||
"Choose your preferred interface language.": "お好みの言語を選択してください",
|
||||
"Choose your preferred page width.": "お好みのページ幅を選択してください",
|
||||
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください。",
|
||||
"Choose your preferred interface language.": "お好みのインターフェース言語を選択してください。",
|
||||
"Choose your preferred page width.": "左右の余白を縮小する場合はオンにしてください。",
|
||||
"Confirm": "確認",
|
||||
"Copy link": "リンクをコピー",
|
||||
"Create": "新規作成",
|
||||
@@ -40,24 +40,24 @@
|
||||
"Date": "日付",
|
||||
"Delete": "削除",
|
||||
"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": "説明",
|
||||
"Details": "詳細",
|
||||
"e.g ACME": "例: 山田太郎",
|
||||
"e.g ACME Inc": "例: 株式会社サンプル",
|
||||
"e.g Developers": "例: エンジニア",
|
||||
"e.g Group for developers": "例: 開発チーム",
|
||||
"e.g Group for developers": "例: エンジニアグループ",
|
||||
"e.g product": "例: product",
|
||||
"e.g Product Team": "例: プロダクトチーム",
|
||||
"e.g Sales": "例: 営業部",
|
||||
"e.g Space for product team": "例: プロダクトチーム用スペース",
|
||||
"e.g Space for sales team to collaborate": "例: 営業チーム用スペース",
|
||||
"e.g Product Team": "例: 製品チーム",
|
||||
"e.g Sales": "例: 営業",
|
||||
"e.g Space for product team": "例: 製品チームのスペース",
|
||||
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
|
||||
"Edit": "編集",
|
||||
"Read": "閲覧",
|
||||
"Read": "読む",
|
||||
"Edit group": "グループを編集",
|
||||
"Email": "メールアドレス",
|
||||
"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 your current password": "現在のパスワードを入力してください",
|
||||
"enter your full name": "氏名を入力してください",
|
||||
@@ -81,18 +81,18 @@
|
||||
"Group description": "グループ説明",
|
||||
"Group name": "グループ名",
|
||||
"Groups": "グループ",
|
||||
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
|
||||
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます。",
|
||||
"Home": "ホーム",
|
||||
"Import pages": "ページをインポート",
|
||||
"Import pages & space settings": "ページとスペース設定をインポート",
|
||||
"Importing pages": "ページをインポートしています",
|
||||
"invalid invitation link": "無効な招待リンクです",
|
||||
"invalid invitation link": "招待リンクが間違っています",
|
||||
"Invitation signup": "招待登録",
|
||||
"Invite by email": "メールアドレスで招待する",
|
||||
"Invite members": "メンバーを招待する",
|
||||
"Invite new members": "新しいメンバーを招待する",
|
||||
"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 who are yet to accept their invitation will appear here.": "招待をまだ承諾していないメンバーはここに表示されます。",
|
||||
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーは、グループがアクセスできるスペースにアクセス権が付与されます",
|
||||
"Join the workspace": "ワークスペースに参加",
|
||||
"Language": "言語",
|
||||
"Light": "ライト",
|
||||
@@ -113,20 +113,20 @@
|
||||
"New page": "新規ページ",
|
||||
"New password": "新しいパスワード",
|
||||
"No group found": "グループが見つかりません",
|
||||
"No page history saved yet.": "ページ履歴がありません",
|
||||
"No page history saved yet.": "まだページの履歴が保存されていません。",
|
||||
"No pages yet": "ページがありません",
|
||||
"No results found...": "結果が見つかりません",
|
||||
"No user found": "ユーザーが見つかりません",
|
||||
"No results found...": "結果が見つかりませんでした...",
|
||||
"No user found": "ユーザがいません",
|
||||
"Overview": "概要",
|
||||
"Owner": "所有者",
|
||||
"page": "ページ",
|
||||
"Page deleted successfully": "ページを削除しました",
|
||||
"Page history": "ページ履歴",
|
||||
"Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください",
|
||||
"Page deleted successfully": "ページが正常に削除されました",
|
||||
"Page history": "ページの履歴",
|
||||
"Page import is in progress. Please do not close this tab.": "ページのインポートが進行中です。このタブを閉じないでください。",
|
||||
"Pages": "ページ",
|
||||
"pages": "ページ",
|
||||
"Password": "パスワード",
|
||||
"Password changed successfully": "パスワードを変更しました",
|
||||
"Password changed successfully": "パスワードが正常に変更されました",
|
||||
"Pending": "保留中",
|
||||
"Please confirm your action": "アクションを確認してください",
|
||||
"Preferences": "設定",
|
||||
@@ -143,95 +143,95 @@
|
||||
"Search for groups": "グループを検索",
|
||||
"Search for users": "ユーザーを検索",
|
||||
"Search for users and groups": "ユーザーとグループを検索",
|
||||
"Search...": "検索",
|
||||
"Search...": "検索する語句を入力",
|
||||
"Select language": "言語を選択",
|
||||
"Select role": "ロールを選択",
|
||||
"Select role to assign to all invited members": "招待するメンバーに割り当てるロールを選択",
|
||||
"Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください",
|
||||
"Select theme": "テーマを選択",
|
||||
"Send invitation": "招待を送る",
|
||||
"Invitation sent": "招待を送信しました",
|
||||
"Invitation sent": "招待が送信されました",
|
||||
"Settings": "設定",
|
||||
"Setup workspace": "ワークスペースを設定する",
|
||||
"Sign In": "サインイン",
|
||||
"Sign Up": "新規登録",
|
||||
"Slug": "スラッグ(URL識別子)",
|
||||
"Sign Up": "アカウント登録",
|
||||
"Slug": "Slug (URL用文字列)",
|
||||
"Space": "スペース",
|
||||
"Space description": "スペース説明",
|
||||
"Space menu": "スペースメニュー",
|
||||
"Space name": "スペース名",
|
||||
"Space settings": "スペース設定",
|
||||
"Space slug": "スペースのスラッグ(URL識別子)",
|
||||
"Space slug": "スペースのSlug (URL用文字列)",
|
||||
"Spaces": "スペース",
|
||||
"Spaces you belong to": "所属しているスペース",
|
||||
"No space found": "スペースが見つかりません",
|
||||
"Search for spaces": "スペースを検索",
|
||||
"Start typing to search...": "入力して検索",
|
||||
"Start typing to search...": "検索を開始するには入力してください...",
|
||||
"Status": "ステータス",
|
||||
"Successfully imported": "インポートしました",
|
||||
"Successfully restored": "復元しました",
|
||||
"Successfully imported": "インポートに成功しました",
|
||||
"Successfully restored": "正常に復元されました",
|
||||
"System settings": "システム設定",
|
||||
"Theme": "テーマ",
|
||||
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力してください",
|
||||
"Toggle full page width": "ページ幅を切り替え",
|
||||
"Unable to import pages. Please try again.": "ページをインポートできませんでした。もう一度お試しください",
|
||||
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力する必要があります。",
|
||||
"Toggle full page width": "ページ幅を切り替える",
|
||||
"Unable to import pages. Please try again.": "ページをインポートできません。もう一度お試しください。",
|
||||
"untitled": "無題",
|
||||
"Untitled": "無題",
|
||||
"Updated successfully": "更新しました",
|
||||
"Updated successfully": "正常に更新されました",
|
||||
"User": "ユーザー",
|
||||
"Workspace": "ワークスペース",
|
||||
"Workspace Name": "ワークスペース名",
|
||||
"Workspace settings": "ワークスペース設定",
|
||||
"You can change your password here.": "パスワードを変更できます",
|
||||
"You can change your password here.": "パスワードを変更できます。",
|
||||
"Your Email": "メールアドレス",
|
||||
"Your import is complete.": "インポートが完了しました",
|
||||
"Your import is complete.": "インポートが完了しました。",
|
||||
"Your name": "名前",
|
||||
"Your Name": "名前",
|
||||
"Your password": "パスワード",
|
||||
"Your password must be a minimum of 8 characters.": "パスワードは8文字以上にしてください",
|
||||
"Your password must be a minimum of 8 characters.": "パスワードは最低 8 文字必要です。",
|
||||
"Sidebar toggle": "サイドバー切り替え",
|
||||
"Comments": "コメント",
|
||||
"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": "ホームに戻る",
|
||||
"Forgot password": "パスワードを忘れた",
|
||||
"Forgot your password?": "パスワードを忘れましたか?",
|
||||
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセット用のリンクをメールに送信しました。受信トレイを確認してください",
|
||||
"Send reset link": "リセットリンクを送信",
|
||||
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセットリンクがあなたのメールアドレスに送信されました。受信箱を確認してください。",
|
||||
"Send reset link": "リセットリンクを送る",
|
||||
"Password reset": "パスワードリセット",
|
||||
"Your new password": "新しいパスワード",
|
||||
"Set password": "パスワードを設定",
|
||||
"Write a comment": "コメントを書く",
|
||||
"Reply...": "返信...",
|
||||
"Error loading comments.": "コメントの読み込みに失敗しました",
|
||||
"No comments yet.": "コメントがありません",
|
||||
"Error loading comments.": "コメントの読み込み中にエラーが発生しました。",
|
||||
"No comments yet.": "コメントがありません。",
|
||||
"Edit comment": "コメントを編集する",
|
||||
"Delete comment": "コメントを削除する",
|
||||
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
|
||||
"Comment created successfully": "コメントを作成しました",
|
||||
"Error creating comment": "コメントの作成に失敗しました",
|
||||
"Comment updated successfully": "コメントを更新しました",
|
||||
"Comment created successfully": "コメントが作成されました",
|
||||
"Error creating comment": "コメントの作成中にエラーが発生しました",
|
||||
"Comment updated successfully": "コメントが更新されました",
|
||||
"Failed to update comment": "コメントの更新に失敗しました",
|
||||
"Comment deleted successfully": "コメントを削除しました",
|
||||
"Comment deleted successfully": "コメントが削除されました",
|
||||
"Failed to delete comment": "コメントの削除に失敗しました",
|
||||
"Comment resolved successfully": "コメントを解決しました",
|
||||
"Comment re-opened successfully": "コメントを再開しました",
|
||||
"Comment unresolved successfully": "コメントを未解決に戻しました",
|
||||
"Comment resolved successfully": "コメントが解決されました",
|
||||
"Comment re-opened successfully": "コメントが再開されました",
|
||||
"Comment unresolved successfully": "コメントが再解決されました",
|
||||
"Failed to resolve comment": "コメントの解決に失敗しました",
|
||||
"Resolve comment": "コメントを解決",
|
||||
"Unresolve comment": "コメントを未解決に戻す",
|
||||
"Unresolve comment": "コメントを再解決",
|
||||
"Resolve 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 unresolve this 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 unresolve this comment thread?": "このコメントスレッドを再解決しますか?",
|
||||
"Resolved": "解決済",
|
||||
"No active comments.": "アクティブなコメントはありません",
|
||||
"No resolved comments.": "解決済みのコメントはありません",
|
||||
"No active comments.": "アクティブなコメントはありません。",
|
||||
"No resolved comments.": "解決されたコメントはありません。",
|
||||
"Revoke invitation": "招待を取り消す",
|
||||
"Revoke": "取り消す",
|
||||
"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": "招待を再度送る",
|
||||
"Anyone with this link can join this workspace.": "このリンクを知っている人は誰でもワークスペースに参加できます",
|
||||
"Anyone with this link can join this workspace.": "このリンクを持っている人は誰でもこのワークスペースに参加できます。",
|
||||
"Invite link": "招待リンク",
|
||||
"Copy": "コピー",
|
||||
"Copy to space": "スペースにコピー",
|
||||
@@ -239,13 +239,13 @@
|
||||
"Duplicate": "複製",
|
||||
"Select a user": "ユーザを選択",
|
||||
"Select a group": "グループを選択",
|
||||
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
|
||||
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします。",
|
||||
"Delete space": "スペースを削除",
|
||||
"Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?",
|
||||
"Delete this space with all its pages and data.": "このスペースとすべてのページ、データを削除します",
|
||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "スペース内のすべてのページ、コメント、添付ファイル、権限が完全に削除されます",
|
||||
"Delete this space with all its pages and data.": "このスペースおよびスペース内のすべてのページとデータを削除します。",
|
||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "このスペース内のすべてのページ、コメント、添付ファイル、および権限は完全に削除されます。",
|
||||
"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": "フォーマット",
|
||||
"Include subpages": "サブページを含める",
|
||||
"Include attachments": "添付ファイルを含める",
|
||||
@@ -273,12 +273,12 @@
|
||||
"Success": "成功",
|
||||
"Warning": "警告",
|
||||
"Danger": "危険",
|
||||
"Mermaid diagram error:": "Mermaid ダイアグラムエラー:",
|
||||
"Invalid Mermaid diagram": "無効な Mermaid ダイアグラムです",
|
||||
"Double-click to edit Draw.io diagram": "ダブルクリックして Draw.io 図を編集",
|
||||
"Mermaid diagram error:": "Mermaid コードエラー",
|
||||
"Invalid Mermaid diagram": "無効な Mermaid コードです",
|
||||
"Double-click to edit Draw.io diagram": "ダブルクリックしてDraw.ioの図を編集",
|
||||
"Exit": "終了",
|
||||
"Save & Exit": "保存して終了",
|
||||
"Double-click to edit Excalidraw diagram": "ダブルクリックして Excalidraw 図を編集",
|
||||
"Double-click to edit Excalidraw diagram": "ダブルクリックしてExcalidraw図を編集",
|
||||
"Paste link": "リンクを貼り付け",
|
||||
"Edit link": "リンクを編集",
|
||||
"Remove link": "リンクを削除",
|
||||
@@ -315,22 +315,22 @@
|
||||
"Bullet List": "箇条書きリスト",
|
||||
"Numbered List": "番号付きリスト",
|
||||
"Blockquote": "引用",
|
||||
"Just start typing with plain text.": "プレーンテキストを入力します",
|
||||
"Track tasks with a to-do list.": "Todo リストでタスクを管理します",
|
||||
"Big section heading.": "大見出し",
|
||||
"Medium section heading.": "中見出し",
|
||||
"Small section heading.": "小見出し",
|
||||
"Create a simple bullet list.": "箇条書きリストを作成します",
|
||||
"Create a list with numbering.": "番号付きリストを作成します",
|
||||
"Create block quote.": "引用ブロックを作成します",
|
||||
"Insert code snippet.": "コードスニペットを挿入します",
|
||||
"Insert horizontal rule divider": "区切り線を挿入します",
|
||||
"Upload any image from your device.": "デバイスから画像をアップロードします",
|
||||
"Upload any video from your device.": "デバイスから動画をアップロードします",
|
||||
"Upload any file from your device.": "デバイスからファイルをアップロードします",
|
||||
"Just start typing with plain text.": "すぐに文章を書き始められます。",
|
||||
"Track tasks with a to-do list.": "Todoリストでタスクを追跡します。",
|
||||
"Big section heading.": "大きいフォントのセクション見出しです。",
|
||||
"Medium section heading.": "中くらいのフォントのセクション見出しです。",
|
||||
"Small section heading.": "小さいフォントのセクション見出しです。",
|
||||
"Create a simple bullet list.": "シンプルな箇条書きのリストを作成します。",
|
||||
"Create a list with numbering.": "番号付きのリストを作成します。",
|
||||
"Create block quote.": "引用文を作成します。",
|
||||
"Insert code snippet.": "コードスニペットを入力します。",
|
||||
"Insert horizontal rule divider": "水平線を挿入します。",
|
||||
"Upload any image from your device.": "画像をアップロードします。",
|
||||
"Upload any video from your device.": "動画をアップロードします。",
|
||||
"Upload any file from your device.": "ファイルをアップロードします。",
|
||||
"Table": "テーブル",
|
||||
"Insert a table.": "テーブルを挿入します",
|
||||
"Insert collapsible block.": "折りたたみブロックを挿入します",
|
||||
"Insert a table.": "表を挿入します。",
|
||||
"Insert collapsible block.": "折りたたみ可能なブロックを挿入します。",
|
||||
"Video": "動画",
|
||||
"Divider": "区切り線",
|
||||
"Quote": "引用",
|
||||
@@ -338,16 +338,16 @@
|
||||
"File attachment": "ファイル添付",
|
||||
"Toggle block": "ブロックを切り替える",
|
||||
"Callout": "コールアウト",
|
||||
"Insert callout notice.": "コールアウトを挿入します",
|
||||
"Insert callout notice.": "コールアウトブロックを挿入します。",
|
||||
"Math inline": "インライン数式",
|
||||
"Insert inline math equation.": "インライン数式を挿入します",
|
||||
"Insert inline math equation.": "インライン数式を挿入します。",
|
||||
"Math block": "数式ブロック",
|
||||
"Insert math equation": "数式を挿入します",
|
||||
"Mermaid diagram": "Mermaid ダイアグラム",
|
||||
"Insert mermaid diagram": "Mermaid ダイアグラムを挿入します",
|
||||
"Insert and design Drawio diagrams": "Draw.io 図を挿入・編集します",
|
||||
"Insert current date": "現在の日付を挿入します",
|
||||
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
|
||||
"Mermaid diagram": "Mermaidコード",
|
||||
"Insert mermaid diagram": "Mermaidコードを記述して図を挿入します",
|
||||
"Insert and design Drawio diagrams": "Drawioの図を挿入してデザインします",
|
||||
"Insert current date": "今日の日付を挿入します",
|
||||
"Draw and sketch excalidraw diagrams": "Excalidrawの図を埋め込みます",
|
||||
"Multiple": "複数",
|
||||
"Heading {{level}}": "見出し {{level}}",
|
||||
"Toggle title": "タイトルの表示/非表示を切り替える",
|
||||
@@ -357,29 +357,29 @@
|
||||
"Yesterday, {{time}}": "昨日、{{time}}",
|
||||
"Space created successfully": "スペースを作成しました",
|
||||
"Space updated successfully": "スペースを更新しました",
|
||||
"Space deleted successfully": "スペースを削除しました",
|
||||
"Space deleted successfully": "スペースが削除されました",
|
||||
"Members added successfully": "メンバーを追加しました",
|
||||
"Member removed successfully": "メンバーを削除しました",
|
||||
"Member removed successfully": "メンバーが削除されました",
|
||||
"Member role updated successfully": "メンバーのロールを更新しました",
|
||||
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "作成日: {{time}}",
|
||||
"Created at: {{time}}": "が作成しました:{{time}}",
|
||||
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "単語数: {{wordCount}}",
|
||||
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
|
||||
"New update": "新規更新",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} が利用可能です",
|
||||
"{{latestVersion}} is available": "{{latestVersion}}は利用可能です",
|
||||
"Default page edit mode": "デフォルトのページ編集モード",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "お好みのページ編集モードを選択してください(誤編集を防止します)",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ぎます。",
|
||||
"Reading": "読み取り",
|
||||
"Delete member": "メンバーを削除する",
|
||||
"Member deleted successfully": "メンバーを削除しました",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "このメンバーを削除してもよろしいですか?この操作は取り消せません",
|
||||
"Member deleted successfully": "メンバーが削除されました",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"Move": "移動",
|
||||
"Move page": "ページを移動",
|
||||
"Move page to a different space.": "ページを別のスペースに移動します",
|
||||
"Real-time editor connection lost. Retrying...": "リアルタイム編集の接続が切断されました。再接続中...",
|
||||
"Move page to a different space.": "ページを別のスペースに移動します。",
|
||||
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
|
||||
"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": "共有",
|
||||
"Public sharing": "公開共有",
|
||||
"Shared by": "共有者",
|
||||
@@ -398,13 +398,13 @@
|
||||
"Delete share": "共有を削除",
|
||||
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
|
||||
"Share deleted successfully": "共有を削除しました",
|
||||
"Share deleted successfully": "共有が正常に削除されました",
|
||||
"Share not found": "共有が見つかりません",
|
||||
"Failed to share page": "ページの共有に失敗しました",
|
||||
"Copy page": "ページをコピー",
|
||||
"Copy page to a different space.": "ページを別のスペースにコピーします",
|
||||
"Page copied successfully": "ページをコピーしました",
|
||||
"Page duplicated successfully": "ページを複製しました",
|
||||
"Copy page to a different space.": "ページを別のスペースにコピーします。",
|
||||
"Page copied successfully": "ページのコピーに成功しました",
|
||||
"Page duplicated successfully": "ページが正常に複製されました",
|
||||
"Find": "検索",
|
||||
"Not found": "見つかりません",
|
||||
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
|
||||
@@ -419,26 +419,26 @@
|
||||
"Error": "エラー",
|
||||
"Failed to disable MFA": "MFAの無効化に失敗しました",
|
||||
"Disable two-factor authentication": "二要素認証を無効化",
|
||||
"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:": "二要素認証を無効にするにはパスワードを入力してください",
|
||||
"Two-factor authentication has been enabled": "二要素認証を有効にしました",
|
||||
"Two-factor authentication has been disabled": "二要素認証を無効にしました",
|
||||
"2-step verification": "2段階認証",
|
||||
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証でアカウントを保護します",
|
||||
"Two-factor authentication is active on your account.": "二要素認証が有効です",
|
||||
"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:": "二要素認証を無効化するにはパスワードを入力してください:",
|
||||
"Two-factor authentication has been enabled": "二要素認証が有効になりました",
|
||||
"Two-factor authentication has been disabled": "二要素認証が無効になりました",
|
||||
"2-step verification": "2段階確認",
|
||||
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証レイヤーでアカウントを保護します。",
|
||||
"Two-factor authentication is active on your account.": "二要素認証がアカウントで有効です。",
|
||||
"Add 2FA method": "2FAメソッドを追加",
|
||||
"Backup codes": "バックアップコード",
|
||||
"Disable": "無効にする",
|
||||
"Invalid verification code": "無効な認証コード",
|
||||
"New backup codes have been generated": "新しいバックアップコードを生成しました",
|
||||
"New backup codes have been generated": "新しいバックアップコードが生成されました",
|
||||
"Failed to regenerate 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回のみ使用可能です",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing 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.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
|
||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "いつでも新しいバックアップコードを再生成できます。これにより、既存のすべてのコードが無効になります。",
|
||||
"Confirm password": "パスワードを確認",
|
||||
"Generate 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": "新しいバックアップコード",
|
||||
"I've saved my backup codes": "バックアップコードを保存しました",
|
||||
"Failed to setup MFA": "MFAの設定に失敗しました",
|
||||
@@ -449,51 +449,51 @@
|
||||
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
|
||||
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
|
||||
"Verify and enable": "確認と有効化",
|
||||
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。もう一度お試しください",
|
||||
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。再試行してください。",
|
||||
"Backup": "バックアップ",
|
||||
"Save 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": "印刷",
|
||||
"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": "二要素認証が必要です",
|
||||
"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.": "ワークスペースにアクセスするには二要素認証を設定してください。アカウントのセキュリティが強化されます",
|
||||
"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.": "ワークスペースへのアクセスを続けるには、二要素認証を設定する必要があります。これにより、アカウントに追加のセキュリティ層が追加されます。",
|
||||
"Set up two-factor authentication": "二要素認証を設定",
|
||||
"Cancel and logout": "キャンセルしてログアウト",
|
||||
"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.": "認証アプリからの確認コードでアカウントのセキュリティが強化されます",
|
||||
"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.": "これにより、認証アプリからの確認コードが必要となり、アカウントに追加のセキュリティ層が追加されます。",
|
||||
"Password is required": "パスワードが必要です",
|
||||
"Password must be at least 8 characters": "パスワードは8文字以上必要です",
|
||||
"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桁のコードを入力してください",
|
||||
"Need help authenticating?": "認証に関するヘルプが必要ですか?",
|
||||
"MFA QR Code": "MFA QRコード",
|
||||
"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 to set up two-factor authentication.": "パスワードをリセットしました。新しいパスワードでログインして二要素認証を設定してください",
|
||||
"Password reset was successful. Please log in with your new password.": "パスワードをリセットしました。新しいパスワードでログインしてください",
|
||||
"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 to set up two-factor authentication.": "パスワードのリセットが成功しました。二要素認証を設定するために新しいパスワードでログインしてください。",
|
||||
"Password reset was successful. Please log in with your new password.": "パスワードのリセットが成功しました。新しいパスワードでログインしてください。",
|
||||
"Two-factor authentication": "二要素認証",
|
||||
"Use authenticator app instead": "代わりに認証アプリを使用",
|
||||
"Verify backup code": "バックアップコードを確認",
|
||||
"Use backup code": "バックアップコードを使用",
|
||||
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
|
||||
"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": "確認",
|
||||
"Trash": "ごみ箱",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます。",
|
||||
"Deleted": "削除",
|
||||
"No pages in trash": "ごみ箱にページがありません",
|
||||
"Permanently delete page?": "ページを完全に削除しますか?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "「{{title}}」を完全に削除しますか?この操作は取り消せません",
|
||||
"Restore '{{title}}' and its sub-pages?": "「{{title}}」とそのサブページを復元しますか?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}』を完全に削除しますか? この操作は元に戻せません。",
|
||||
"Restore '{{title}}' and its sub-pages?": "{{title}}』とそのサブページを復元しますか?",
|
||||
"Move to trash": "ごみ箱に移動",
|
||||
"Move this page to trash?": "このページをごみ箱に移動しますか?",
|
||||
"Restore page": "ページを復元",
|
||||
"Page moved to trash": "ページをごみ箱に移動しました",
|
||||
"Page restored successfully": "ページを復元しました",
|
||||
"Page moved to trash": "ページがごみ箱に移動されました",
|
||||
"Page restored successfully": "ページが正常に復元されました",
|
||||
"Deleted by": "削除者",
|
||||
"Deleted at": "削除日時",
|
||||
"Preview": "プレビュー",
|
||||
@@ -511,10 +511,10 @@
|
||||
"Enterprise": "エンタープライズ",
|
||||
"Download attachment": "添付ファイルをダウンロード",
|
||||
"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": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
|
||||
"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の強制を切り替える",
|
||||
"Display name": "表示名",
|
||||
"Allow signup": "登録を許可する",
|
||||
@@ -532,10 +532,10 @@
|
||||
"Upload image": "画像をアップロード",
|
||||
"Remove image": "画像を削除",
|
||||
"Failed to remove image": "画像の削除に失敗しました",
|
||||
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
|
||||
"Image removed successfully": "画像を削除しました",
|
||||
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています。",
|
||||
"Image removed successfully": "画像が正常に削除されました",
|
||||
"API key": "APIキー",
|
||||
"API key created successfully": "APIキーを作成しました",
|
||||
"API key created successfully": "APIキーが正常に作成されました",
|
||||
"API keys": "APIキー",
|
||||
"API management": "API管理",
|
||||
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
|
||||
@@ -550,9 +550,9 @@
|
||||
"No API keys found": "APIキーが見つかりません",
|
||||
"No expiration": "期限なし",
|
||||
"Revoke API key": "APIキーを無効にする",
|
||||
"Revoked successfully": "無効にしました",
|
||||
"Revoked successfully": "正常に無効化されました",
|
||||
"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キーを更新",
|
||||
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
|
||||
"AI settings": "AI設定",
|
||||
@@ -562,7 +562,7 @@
|
||||
"AI is thinking...": "AIが考え中...",
|
||||
"Ask a question...": "質問を入力...",
|
||||
"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検索を切り替え",
|
||||
"Sources": "ソース",
|
||||
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
|
||||
|
||||
@@ -5,27 +5,26 @@ import {
|
||||
Badge,
|
||||
Table,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
} from '@mantine/core';
|
||||
import {Link} from 'react-router-dom';
|
||||
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
|
||||
import { buildPageUrl } from '@/features/page/page.utils.ts';
|
||||
import { formattedDate } from '@/lib/time.ts';
|
||||
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
|
||||
import { IconFileDescription } from '@tabler/icons-react';
|
||||
import { getSpaceUrl } from '@/lib/config.ts';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
||||
|
||||
interface Props {
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export default function RecentChanges({ spaceId }: Props) {
|
||||
export default function RecentChanges({spaceId}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
|
||||
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton />;
|
||||
return <PageListSkeleton/>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
@@ -45,8 +44,8 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ActionIcon variant="transparent" color="gray" size={18}>
|
||||
<IconFileDescription size={18} />
|
||||
<ActionIcon variant='transparent' color='gray' size={18}>
|
||||
<IconFileDescription size={18}/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
@@ -59,23 +58,18 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
{!spaceId && (
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
color="blue"
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
style={{cursor: 'pointer'}}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</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 { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
|
||||
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import { formatBytes } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function AttachmentView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected } = props;
|
||||
const { url, name, size } = node.attrs;
|
||||
const { hovered, ref } = useHover();
|
||||
@@ -22,28 +20,26 @@ export default function AttachmentView(props: NodeViewProps) {
|
||||
wrap="nowrap"
|
||||
h={25}
|
||||
>
|
||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
|
||||
{url ? (
|
||||
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
)}
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<IconPaperclip size={20} />
|
||||
|
||||
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
|
||||
{url ? name : t("Uploading {{name}}", { name })}
|
||||
<Text component="span" size="md" truncate="end">
|
||||
{name}
|
||||
</Text>
|
||||
|
||||
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
<Text component="span" size="sm" c="dimmed" inline>
|
||||
{formatBytes(size)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{url && (selected || hovered) && (
|
||||
{selected || hovered ? (
|
||||
<a href={getFileUrl(url)} target="_blank">
|
||||
<ActionIcon variant="default" aria-label="download file">
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
||||
import { isNodeSelection, useEditorState } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import {
|
||||
BubbleMenu,
|
||||
BubbleMenuProps,
|
||||
isNodeSelection,
|
||||
useEditor,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
IconBold,
|
||||
@@ -34,7 +38,7 @@ export interface BubbleMenuItem {
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||
editor: Editor | null;
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
};
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
@@ -129,9 +133,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
}
|
||||
return isTextSelected(editor);
|
||||
},
|
||||
options: {
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
tippyOptions: {
|
||||
moveTransition: "transform 0.15s ease-out",
|
||||
onCreate: (instance) => {
|
||||
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
},
|
||||
onHide: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
@@ -147,7 +156,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
||||
<BubbleMenu {...bubbleMenuProps}>
|
||||
<div className={classes.bubbleMenu}>
|
||||
<NodeSelector
|
||||
editor={props.editor}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
@@ -49,26 +53,17 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "callout";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const setCalloutType = useCallback(
|
||||
@@ -117,12 +112,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`callout-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 10],
|
||||
placement: "bottom",
|
||||
// offset: 233, // // offset: [0, 10],
|
||||
// zIndex: 99,
|
||||
flip: false,
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -90,7 +90,6 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
node.textContent.length > 0
|
||||
}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<NodeViewContent as="code" className={`language-${language}`} />
|
||||
</pre>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||
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 { Editor } from "@tiptap/core";
|
||||
|
||||
export const handlePaste = (
|
||||
editor: Editor,
|
||||
view: EditorView,
|
||||
event: ClipboardEvent,
|
||||
pageId: 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
|
||||
event.preventDefault();
|
||||
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 currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
||||
|
||||
@@ -33,27 +34,19 @@ export const handlePaste = (
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
|
||||
const urlWithoutAnchor = anchorId
|
||||
? url.substring(0, url.indexOf("#"))
|
||||
: url;
|
||||
createMentionAction(
|
||||
urlWithoutAnchor,
|
||||
editor.view,
|
||||
pos,
|
||||
creatorId,
|
||||
anchorId,
|
||||
);
|
||||
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
|
||||
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
|
||||
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.clipboardData?.files.length) {
|
||||
event.preventDefault();
|
||||
for (const file of event.clipboardData.files) {
|
||||
const pos = editor.state.selection.from;
|
||||
uploadImageAction(file, editor, pos, pageId);
|
||||
uploadVideoAction(file, editor, pos, pageId);
|
||||
uploadAttachmentAction(file, editor, pos, pageId);
|
||||
const pos = view.state.selection.from;
|
||||
uploadImageAction(file, view, pos, pageId);
|
||||
uploadVideoAction(file, view, pos, pageId);
|
||||
uploadAttachmentAction(file, view, pos, pageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -61,7 +54,7 @@ export const handlePaste = (
|
||||
};
|
||||
|
||||
export const handleFileDrop = (
|
||||
editor: Editor,
|
||||
view: EditorView,
|
||||
event: DragEvent,
|
||||
moved: boolean,
|
||||
pageId: string,
|
||||
@@ -70,14 +63,14 @@ export const handleFileDrop = (
|
||||
event.preventDefault();
|
||||
|
||||
for (const file of event.dataTransfer.files) {
|
||||
const coordinates = editor.view.posAtCoords({
|
||||
const coordinates = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { sticky } from "tippy.js";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
@@ -35,26 +40,17 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "drawio";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
@@ -69,11 +65,15 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`drawio-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -66,7 +66,6 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
const fileName = "diagram.drawio.svg";
|
||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
//@ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||
import EmojiList from "./emoji-list";
|
||||
import tippy from "tippy.js";
|
||||
import { init } from "emoji-mart";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
|
||||
const renderEmojiItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: HTMLDivElement | 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;
|
||||
}
|
||||
};
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onBeforeStart: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: () => DOMRect;
|
||||
clientRect: DOMRect;
|
||||
}) => {
|
||||
init({
|
||||
data: async () => (await import("@emoji-mart/data")).default,
|
||||
@@ -50,61 +25,51 @@ const renderEmojiItems = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
getReferenceClientRect = props.clientRect;
|
||||
popup = document.createElement("div");
|
||||
popup.style.zIndex = "9999";
|
||||
popup.style.position = "absolute";
|
||||
popup.style.top = "0";
|
||||
popup.style.left = "0";
|
||||
popup.appendChild(component.element);
|
||||
document.body.appendChild(popup);
|
||||
|
||||
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)`,
|
||||
});
|
||||
});
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom",
|
||||
});
|
||||
},
|
||||
onStart: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: () => DOMRect;
|
||||
clientRect: DOMRect;
|
||||
}) => {
|
||||
component?.updateProps({ ...props, isLoading: false });
|
||||
component?.updateProps({...props, isLoading: false});
|
||||
|
||||
if (props.clientRect) {
|
||||
getReferenceClientRect = props.clientRect;
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onUpdate: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: () => DOMRect;
|
||||
clientRect: DOMRect;
|
||||
}) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
if (props.clientRect) {
|
||||
getReferenceClientRect = props.clientRect;
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
destroy();
|
||||
popup?.[0].hide();
|
||||
component?.destroy()
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -113,7 +78,13 @@ const renderEmojiItems = () => {
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
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 { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { sticky } from "tippy.js";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
@@ -37,26 +42,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
@@ -69,13 +65,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`excalidraw-menu`}
|
||||
pluginKey={`excalidraw-menu}`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -98,7 +98,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
const fileName = "diagram.excalidraw.svg";
|
||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { sticky } from "tippy.js";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("image");
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -37,37 +52,17 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("image") && editor.getAttributes("image").src;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "image";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const alignImageLeft = useCallback(() => {
|
||||
@@ -110,11 +105,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`image-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
}}
|
||||
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 { Group, Image, Loader, Text } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { Image } from "@mantine/core";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import clsx from "clsx";
|
||||
import classes from "./image-view.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ImageView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, selected } = props;
|
||||
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
|
||||
const { node, selected } = props;
|
||||
const { src, width, align, title } = node.attrs;
|
||||
|
||||
const alignClass = useMemo(() => {
|
||||
if (align === "left") return "alignLeft";
|
||||
if (align === "right") return "alignRight";
|
||||
if (align === "center") return "alignCenter";
|
||||
return "alignCenter";
|
||||
}, [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 (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
classes.imageWrapper,
|
||||
alignClass,
|
||||
)}
|
||||
style={{
|
||||
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>
|
||||
<Image
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||
/>
|
||||
</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 { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
||||
import { Card } from "@mantine/core";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
|
||||
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
@@ -60,15 +59,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`link-menu`}
|
||||
pluginKey={`link-menu}`}
|
||||
updateDelay={0}
|
||||
options={{
|
||||
onHide: () => {
|
||||
tippyOptions={{
|
||||
appendTo: () => {
|
||||
return appendTo?.current;
|
||||
},
|
||||
onHidden: () => {
|
||||
setShowEdit(false);
|
||||
},
|
||||
placement: "bottom",
|
||||
offset: 5,
|
||||
// zIndex: 101,
|
||||
offset: [0, 5],
|
||||
zIndex: 101,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -106,7 +106,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
|
||||
setRenderItems(items);
|
||||
// update editor storage
|
||||
//@ts-ignore
|
||||
props.editor.storage.mentionItems = items;
|
||||
}
|
||||
}, [suggestion, isLoading]);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import tippy from "tippy.js";
|
||||
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
|
||||
|
||||
function getWhitespaceCount(query: string) {
|
||||
@@ -15,27 +9,16 @@ function getWhitespaceCount(query: string) {
|
||||
|
||||
const mentionRenderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let activeClientRect: (() => DOMRect) | 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;
|
||||
};
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: () => DOMRect;
|
||||
clientRect: DOMRect;
|
||||
query: string;
|
||||
}) => {
|
||||
// query must not start with a whitespace
|
||||
if (props.query.charAt(0) === " ") {
|
||||
if (props.query.charAt(0) === ' '){
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,88 +37,75 @@ const mentionRenderItems = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
activeClientRect = props.clientRect;
|
||||
|
||||
const { element } = component;
|
||||
document.body.appendChild(element);
|
||||
|
||||
updatePositionCleanup = autoUpdate(
|
||||
{
|
||||
getBoundingClientRect: () =>
|
||||
activeClientRect ? activeClientRect() : new DOMRect(),
|
||||
},
|
||||
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",
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: () => DOMRect;
|
||||
clientRect: DOMRect;
|
||||
query: string;
|
||||
}) => {
|
||||
// query must not start with a whitespace
|
||||
if (props.query.charAt(0) === " ") {
|
||||
destroy();
|
||||
if (props.query.charAt(0) === ' '){
|
||||
component?.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// only update component if popup is not destroyed
|
||||
if (component) {
|
||||
component.updateProps(props);
|
||||
if (!popup?.[0].state.isDestroyed) {
|
||||
component?.updateProps(props);
|
||||
}
|
||||
|
||||
if (!props || !props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeClientRect = props.clientRect;
|
||||
|
||||
const whitespaceCount = getWhitespaceCount(props.query);
|
||||
|
||||
// destroy component if space is greater 3 without a match
|
||||
if (
|
||||
whitespaceCount > 3 &&
|
||||
//@ts-ignore
|
||||
props.editor.storage.mentionItems.length === 0
|
||||
) {
|
||||
destroy();
|
||||
popup?.[0]?.destroy();
|
||||
component?.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
popup &&
|
||||
!popup?.[0].state.isDestroyed &&
|
||||
popup?.[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key)
|
||||
if (
|
||||
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 (component?.ref as any)?.onKeyDown(props);
|
||||
},
|
||||
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 { ActionIcon, Anchor, Text } from "@mantine/core";
|
||||
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 {
|
||||
buildPageUrl,
|
||||
buildSharedPageUrl,
|
||||
} from "@/features/page/page.utils.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import classes from "./mention.module.css";
|
||||
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
const { node } = props;
|
||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
const { spaceSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: page,
|
||||
isLoading,
|
||||
@@ -25,20 +23,6 @@ export default function MentionView(props: NodeViewProps) {
|
||||
const location = useLocation();
|
||||
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({
|
||||
shareId,
|
||||
pageSlugId: slugId,
|
||||
@@ -61,7 +45,6 @@ export default function MentionView(props: NodeViewProps) {
|
||||
to={
|
||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
|
||||
}
|
||||
onClick={handleClick}
|
||||
underline="never"
|
||||
className={classes.pageMentionLink}
|
||||
>
|
||||
|
||||
-2
@@ -73,8 +73,6 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
if (!editor) return;
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
//TODO: check type error
|
||||
//@ts-ignore
|
||||
const position: Range = results[resultIndex];
|
||||
|
||||
if (!position) return;
|
||||
|
||||
@@ -161,7 +161,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
@@ -174,13 +173,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
|
||||
uploadImageAction(file, editor, pos, pageId);
|
||||
uploadImageAction(file, editor.view, pos, pageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
@@ -193,7 +188,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
@@ -201,18 +195,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "video/*";
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
|
||||
uploadVideoAction(file, editor, pos, pageId);
|
||||
}
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadVideoAction(file, editor.view, pos, pageId);
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
@@ -225,7 +213,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
@@ -233,18 +220,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "";
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
|
||||
uploadAttachmentAction(file, editor, pos, pageId, true);
|
||||
}
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadAttachmentAction(file, editor.view, pos, pageId, true);
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
|
||||
@@ -1,35 +1,10 @@
|
||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||
import CommandList from "@/features/editor/components/slash-menu/command-list";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import tippy from "tippy.js";
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: HTMLElement | 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`;
|
||||
}
|
||||
});
|
||||
};
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: {
|
||||
@@ -46,29 +21,15 @@ const renderItems = () => {
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
getReferenceClientRect = props.clientRect;
|
||||
|
||||
popup = document.createElement("div");
|
||||
popup.style.zIndex = "9999";
|
||||
popup.style.position = "absolute";
|
||||
popup.style.top = "0";
|
||||
popup.style.left = "0";
|
||||
|
||||
document.body.appendChild(popup);
|
||||
popup.appendChild(component.element);
|
||||
|
||||
cleanup = autoUpdate(
|
||||
// @ts-ignore
|
||||
{
|
||||
getBoundingClientRect: () => {
|
||||
return getReferenceClientRect
|
||||
? getReferenceClientRect()
|
||||
: new DOMRect();
|
||||
},
|
||||
},
|
||||
popup,
|
||||
updatePosition
|
||||
);
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
@@ -80,15 +41,14 @@ const renderItems = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
getReferenceClientRect = props.clientRect;
|
||||
updatePosition();
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
if (popup) {
|
||||
popup.style.display = "none";
|
||||
}
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -97,19 +57,12 @@ const renderItems = () => {
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
}
|
||||
|
||||
if (popup) {
|
||||
popup.remove();
|
||||
popup = null;
|
||||
if (popup && !popup[0].state.isDestroyed) {
|
||||
popup[0].destroy();
|
||||
}
|
||||
|
||||
if (component) {
|
||||
component.destroy();
|
||||
component = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
posToDOMRect,
|
||||
findParentNode,
|
||||
} from "@tiptap/react";
|
||||
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 { IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { sticky } from "tippy.js";
|
||||
|
||||
interface SubpagesMenuProps {
|
||||
editor: Editor;
|
||||
@@ -29,7 +33,7 @@ export const SubpagesMenu = React.memo(
|
||||
|
||||
return editor.isActive("subpages");
|
||||
},
|
||||
[editor]
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
@@ -58,8 +62,18 @@ export const SubpagesMenu = React.memo(
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`subpages-menu`}
|
||||
pluginKey={`subpages-menu}`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<Tooltip position="top" label={t("Delete")}>
|
||||
@@ -75,7 +89,7 @@ export const SubpagesMenu = React.memo(
|
||||
</Tooltip>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default SubpagesMenu;
|
||||
|
||||
@@ -19,7 +19,6 @@ export default function SubpagesView(props: NodeViewProps) {
|
||||
const { spaceSlug, shareId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
//@ts-ignore
|
||||
const currentPageId = editor.storage.pageId;
|
||||
|
||||
// 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 {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
@@ -15,7 +17,6 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TableBackgroundColor } from "./table-background-color";
|
||||
import { TableTextAlignment } from "./table-text-alignment";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
|
||||
export const TableCellMenu = React.memo(
|
||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||
@@ -28,7 +29,7 @@ export const TableCellMenu = React.memo(
|
||||
|
||||
return isCellSelection(state.selection);
|
||||
},
|
||||
[editor]
|
||||
[editor],
|
||||
);
|
||||
|
||||
const mergeCells = useCallback(() => {
|
||||
@@ -52,27 +53,23 @@ export const TableCellMenu = React.memo(
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey="table-cell-menu"
|
||||
updateDelay={0}
|
||||
appendTo={() => {
|
||||
return appendTo?.current;
|
||||
}}
|
||||
ref={(element) => {
|
||||
element.style.zIndex = "99";
|
||||
}}
|
||||
options={{
|
||||
offset: {
|
||||
mainAxis: 15,
|
||||
tippyOptions={{
|
||||
appendTo: () => {
|
||||
return appendTo?.current;
|
||||
},
|
||||
offset: [0, 15],
|
||||
zIndex: 99,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group>
|
||||
<TableBackgroundColor editor={editor} />
|
||||
<TableTextAlignment editor={editor} />
|
||||
|
||||
|
||||
<Tooltip position="top" label={t("Merge cells")}>
|
||||
<ActionIcon
|
||||
onClick={mergeCells}
|
||||
@@ -128,9 +125,9 @@ export const TableCellMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</BubbleMenu>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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 React, { JSX, useCallback } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
@@ -12,12 +17,9 @@ import {
|
||||
IconColumnRemove,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconRowRemove,
|
||||
IconTableColumn,
|
||||
IconTableRow,
|
||||
IconRowRemove, IconTableColumn, IconTableRow,
|
||||
IconTrashX,
|
||||
} from "@tabler/icons-react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
} from '@tabler/icons-react';
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -32,28 +34,20 @@ export const TableMenu = React.memo(
|
||||
|
||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||
},
|
||||
[editor]
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "table";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
const rect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => rect,
|
||||
getClientRects: () => [rect],
|
||||
};
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
const rect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => rect,
|
||||
getClientRects: () => [rect],
|
||||
};
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderColumn = useCallback(() => {
|
||||
@@ -93,33 +87,42 @@ export const TableMenu = React.memo(
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey="table-menu"
|
||||
resizeDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
ref={(element) => {
|
||||
element.style.zIndex = "99";
|
||||
}}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: {
|
||||
mainAxis: 15,
|
||||
},
|
||||
flip: {
|
||||
fallbackPlacements: ["top", "bottom"],
|
||||
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
|
||||
boundary: editor.options.element as HTMLElement,
|
||||
},
|
||||
shift: {
|
||||
padding: 8 + 15,
|
||||
crossAxis: true,
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect: getReferenceClientRect,
|
||||
offset: [0, 15],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
enabled: true,
|
||||
options: {
|
||||
altAxis: true,
|
||||
boundary: "clippingParents",
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
enabled: true,
|
||||
options: {
|
||||
boundary: editor.options.element,
|
||||
fallbackPlacements: ["top", "bottom"],
|
||||
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group>
|
||||
<Tooltip position="top" label={t("Add left column")}>
|
||||
<Tooltip position="top" label={t("Add left column")}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={addColumnLeft}
|
||||
variant="default"
|
||||
@@ -185,7 +188,8 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header row")}>
|
||||
<Tooltip position="top" label={t("Toggle header row")}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderRow}
|
||||
variant="default"
|
||||
@@ -196,7 +200,8 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header column")}>
|
||||
<Tooltip position="top" label={t("Toggle header column")}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderColumn}
|
||||
variant="default"
|
||||
@@ -219,9 +224,9 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</BubbleMenu>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default TableMenu;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { sticky } from "tippy.js";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("video");
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -37,37 +52,17 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("video") && editor.getAttributes("video").src;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "video";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const alignVideoLeft = useCallback(() => {
|
||||
@@ -110,11 +105,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`video-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
}}
|
||||
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 { Group, Loader, Text } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import clsx from "clsx";
|
||||
import classes from "./video-view.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function VideoView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, selected } = props;
|
||||
const { src, width, align, aspectRatio, placeholder } = node.attrs;
|
||||
const { node, selected } = props;
|
||||
const { src, width, align } = node.attrs;
|
||||
|
||||
const alignClass = useMemo(() => {
|
||||
if (align === "left") return "alignLeft";
|
||||
if (align === "right") return "alignRight";
|
||||
if (align === "center") return "alignCenter";
|
||||
return "alignCenter";
|
||||
}, [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 (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
classes.videoWrapper,
|
||||
alignClass,
|
||||
)}
|
||||
style={{
|
||||
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>
|
||||
<video
|
||||
preload="metadata"
|
||||
width={width}
|
||||
controls
|
||||
src={getFileUrl(src)}
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
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 SubScript from "@tiptap/extension-subscript";
|
||||
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 SlashCommand from "@/features/editor/extensions/slash-command";
|
||||
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 {
|
||||
Comment,
|
||||
@@ -37,12 +41,11 @@ import {
|
||||
Embed,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
TableDndExtension,
|
||||
Subpages,
|
||||
TableDndExtension,
|
||||
Heading,
|
||||
Highlight,
|
||||
UniqueID,
|
||||
SharedStorage,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -94,9 +97,7 @@ lowlight.register("scala", scala);
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
undoRedo: false,
|
||||
link: false,
|
||||
trailingNode: false,
|
||||
history: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
color: "#70CFF8",
|
||||
@@ -108,7 +109,6 @@ export const mainExtensions = [
|
||||
},
|
||||
},
|
||||
}),
|
||||
SharedStorage,
|
||||
Heading,
|
||||
UniqueID.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
@@ -134,6 +134,8 @@ export const mainExtensions = [
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
ListKeymap,
|
||||
Underline,
|
||||
LinkExtension.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
@@ -168,9 +170,6 @@ export const mainExtensions = [
|
||||
},
|
||||
}).extend({
|
||||
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);
|
||||
},
|
||||
}),
|
||||
@@ -209,7 +208,6 @@ export const mainExtensions = [
|
||||
}),
|
||||
CustomCodeBlock.configure({
|
||||
view: CodeBlockView,
|
||||
//@ts-ignore
|
||||
lowlight,
|
||||
HTMLAttributes: {
|
||||
spellcheck: false,
|
||||
@@ -260,9 +258,8 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
provider,
|
||||
}),
|
||||
CollaborationCaret.configure({
|
||||
CollaborationCursor.configure({
|
||||
provider,
|
||||
user: {
|
||||
name: user.name,
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
HocuspocusProvider,
|
||||
onStatusParameters,
|
||||
onAuthenticationFailedParameters,
|
||||
WebSocketStatus,
|
||||
HocuspocusProviderWebsocket,
|
||||
onSyncedParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
EditorProvider,
|
||||
useEditor,
|
||||
@@ -78,140 +69,161 @@ export default function PageEditor({
|
||||
editable,
|
||||
content,
|
||||
}: PageEditorProps) {
|
||||
|
||||
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const editorCreated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isComponentMounted.current = true;
|
||||
}, []);
|
||||
|
||||
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setEditor] = useAtom(pageEditorAtom);
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||
const ydocRef = useRef<Y.Doc | null>(null);
|
||||
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(
|
||||
yjsConnectionStatusAtom,
|
||||
);
|
||||
const menuContainerRef = useRef(null);
|
||||
const documentName = `page.${pageId}`;
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
const documentState = useDocumentVisibility();
|
||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const userPageEditMode =
|
||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
const canScroll = useCallback(
|
||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||
[isComponentMounted],
|
||||
);
|
||||
|
||||
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
|
||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
remote: HocuspocusProvider;
|
||||
socket: HocuspocusProviderWebsocket;
|
||||
} | null>(null);
|
||||
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(() => {
|
||||
if (!providersRef.current) {
|
||||
const documentName = `page.${pageId}`;
|
||||
const ydoc = new Y.Doc();
|
||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||
const socket = new HocuspocusProviderWebsocket({
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
local.on("synced", () => setLocalSynced(true));
|
||||
const remote = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
name: documentName,
|
||||
url: collaborationURL,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||
onStatus: onStatusHandler,
|
||||
onSynced: onSyncedHandler,
|
||||
connect: true,
|
||||
preserveConnection: false,
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
local.on("synced", onLocalSyncedHandler);
|
||||
providersRef.current = { socket, local, remote };
|
||||
remote.on("synced", () => setRemoteSynced(true));
|
||||
remote.on("disconnect", () => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
});
|
||||
providersRef.current = { local, remote };
|
||||
setProvidersReady(true);
|
||||
} else {
|
||||
setProvidersReady(true);
|
||||
}
|
||||
// Only destroy on final unmount
|
||||
return () => {
|
||||
providersRef.current?.socket.destroy();
|
||||
providersRef.current?.remote.destroy();
|
||||
providersRef.current?.local.destroy();
|
||||
providersRef.current = null;
|
||||
};
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!providersReady || !providersRef.current) return;
|
||||
const socket = providersRef.current.socket;
|
||||
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
yjsConnectionStatus === WebSocketStatus.Connected
|
||||
remoteProvider.status === WebSocketStatus.Connected
|
||||
) {
|
||||
socket.disconnect();
|
||||
remoteProvider.disconnect();
|
||||
setIsCollabReady(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
||||
remoteProvider.status === WebSocketStatus.Disconnected
|
||||
) {
|
||||
resetIdle();
|
||||
socket.connect();
|
||||
remoteProvider.connect();
|
||||
setTimeout(() => setIsCollabReady(true), 500);
|
||||
}
|
||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||
|
||||
// Attach here, to make sure the connection gets properly established
|
||||
providersRef.current?.remote.attach();
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
||||
return mainExtensions;
|
||||
}
|
||||
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
|
||||
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
];
|
||||
}, [providersReady, currentUser?.user]);
|
||||
}, [remoteProvider, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@@ -254,30 +266,18 @@ export default function PageEditor({
|
||||
}
|
||||
},
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
if (!editorRef.current) return false;
|
||||
|
||||
return handlePaste(
|
||||
editorRef.current,
|
||||
event,
|
||||
pageId,
|
||||
currentUser?.user.id,
|
||||
);
|
||||
},
|
||||
handleDrop: (_view, event, _slice, moved) => {
|
||||
if (!editorRef.current) return false;
|
||||
|
||||
return handleFileDrop(editorRef.current, event, moved, pageId);
|
||||
},
|
||||
handlePaste: (view, event, slice) =>
|
||||
handlePaste(view, event, pageId, currentUser?.user.id),
|
||||
handleDrop: (view, event, _slice, moved) =>
|
||||
handleFileDrop(view, event, moved, pageId),
|
||||
},
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setEditor(editor);
|
||||
// @ts-ignore
|
||||
editor.storage.pageId = pageId;
|
||||
handleScrollTo(editor);
|
||||
editorRef.current = editor;
|
||||
editorCreated.current = true;
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
@@ -287,7 +287,7 @@ export default function PageEditor({
|
||||
debouncedUpdateContent(editorJson);
|
||||
},
|
||||
},
|
||||
[pageId, editable, extensions],
|
||||
[pageId, editable, remoteProvider],
|
||||
);
|
||||
|
||||
const editorIsEditable = useEditorState({
|
||||
@@ -343,17 +343,30 @@ export default function PageEditor({
|
||||
setAsideState({ tab: "", isAsideOpen: false });
|
||||
}, [pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remoteProvider?.status === WebSocketStatus.Connecting) {
|
||||
const timeout = setTimeout(() => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [remoteProvider?.status]);
|
||||
|
||||
const isSynced = isLocalSynced && isRemoteSynced;
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
const collabReadyTimeout = setTimeout(() => {
|
||||
if (
|
||||
!isCollabReady &&
|
||||
isSynced &&
|
||||
remoteProvider?.status === WebSocketStatus.Connected
|
||||
) {
|
||||
setIsCollabReady(true);
|
||||
}
|
||||
}, 7500);
|
||||
}, 500);
|
||||
return () => clearTimeout(collabReadyTimeout);
|
||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
useEffect(() => {
|
||||
// Only honor user default page edit mode preference and permissions
|
||||
if (editor) {
|
||||
@@ -375,13 +388,12 @@ export default function PageEditor({
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
yjsConnectionStatus === WebSocketStatus.Connected &&
|
||||
isSynced
|
||||
remoteProvider?.status === WebSocketStatus.Connected
|
||||
) {
|
||||
hasConnectedOnceRef.current = true;
|
||||
setShowStatic(false);
|
||||
}
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
}, [remoteProvider?.status]);
|
||||
|
||||
if (showStatic) {
|
||||
return (
|
||||
|
||||
@@ -81,7 +81,6 @@ export default function ReadonlyPageEditor({
|
||||
onCreate={({ editor }) => {
|
||||
if (editor) {
|
||||
if (pageId) {
|
||||
// @ts-ignore
|
||||
editor.storage.pageId = pageId;
|
||||
}
|
||||
// @ts-ignore
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Give a remote user a caret */
|
||||
.collaboration-carets__caret {
|
||||
.collaboration-cursor__caret {
|
||||
border-left: 1px solid #0d0d0d;
|
||||
border-right: 1px solid #0d0d0d;
|
||||
margin-left: -1px;
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
|
||||
/* Render the username above the caret */
|
||||
.collaboration-carets__label {
|
||||
.collaboration-cursor__label {
|
||||
border-radius: 3px 3px 3px 0;
|
||||
color: #0d0d0d;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import api from "@/lib/api-client";
|
||||
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> {
|
||||
const req = await api.post<IFileTask>("/file-tasks/info", {
|
||||
@@ -10,10 +8,7 @@ export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getFileTasks(
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<IFileTask>> {
|
||||
const req = await api.post("/file-tasks", { ...params });
|
||||
export async function getFileTasks(): Promise<IFileTask[]> {
|
||||
const req = await api.post<IFileTask[]>("/file-tasks");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { zodResolver } from 'mantine-form-zod-resolver';
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
name: z.string().min(2).max(50),
|
||||
description: z.string().max(500),
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import Paginate from "@/components/common/paginate.tsx";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { getSpaces } from "@/features/space/services/space-service.ts";
|
||||
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
|
||||
export default function GroupList() {
|
||||
const { t } = useTranslation();
|
||||
@@ -51,10 +50,10 @@ export default function GroupList() {
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IconGroupCircle />
|
||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{group.name}
|
||||
</AutoTooltipText>
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{group.description}
|
||||
</Text>
|
||||
|
||||
@@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) {
|
||||
mainEditorTitle
|
||||
.chain()
|
||||
.clearContent()
|
||||
.setContent(activeHistoryData.title, { emitUpdate: true })
|
||||
.setContent(activeHistoryData.title, true)
|
||||
.run();
|
||||
mainEditor
|
||||
.chain()
|
||||
|
||||
@@ -9,14 +9,20 @@ import {
|
||||
IconList,
|
||||
IconMessage,
|
||||
IconPrinter,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React from "react";
|
||||
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 { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
getHotkeyHandler,
|
||||
useClipboard,
|
||||
useDisclosure,
|
||||
useHotkeys,
|
||||
} from "@mantine/hooks";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -32,7 +38,8 @@ import {
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} 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 MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
@@ -44,6 +51,7 @@ interface PageHeaderMenuProps {
|
||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const toggleAside = useToggleAside();
|
||||
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||
|
||||
useHotkeys(
|
||||
[
|
||||
@@ -67,7 +75,17 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
|
||||
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" />}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>(null);
|
||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||
const rootElement = useRef<HTMLDivElement>(null);
|
||||
const rootElement = useRef<HTMLDivElement>();
|
||||
const [isRootReady, setIsRootReady] = useState(false);
|
||||
const { ref: sizeRef, width, height } = useElementSize();
|
||||
const mergedRef = useMergedRef((element) => {
|
||||
@@ -269,15 +269,12 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
|
||||
const prefetchPage = () => {
|
||||
timerRef.current = setTimeout(async () => {
|
||||
const page = await queryClient.fetchQuery({
|
||||
queryKey: ["pages", node.data.id],
|
||||
queryFn: () => getPageById({ pageId: node.data.id }),
|
||||
timerRef.current = setTimeout(() => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["pages", node.data.slugId],
|
||||
queryFn: () => getPageById({ pageId: node.data.slugId }),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
if (page?.slugId) {
|
||||
queryClient.setQueryData(["pages", page.slugId], page);
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
@@ -20,12 +21,12 @@ import {
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { extractPageSlugId, getPageIcon } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl, isCloud } from "@/lib/config.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import classes from "@/features/share/components/share.module.css";
|
||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||
|
||||
interface ShareModalProps {
|
||||
readOnly: boolean;
|
||||
@@ -34,9 +35,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { pageSlug } = useParams();
|
||||
const pageSlugId = extractPageSlugId(pageSlug);
|
||||
const { data: page } = usePageQuery({ pageId: pageSlugId });
|
||||
const pageId = page?.id;
|
||||
const pageId = extractPageSlugId(pageSlug);
|
||||
const { data: share } = useShareForPageQuery(pageId);
|
||||
const { spaceSlug } = useParams();
|
||||
const { isTrial } = useTrial();
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function SharedTree({ sharedPageTree }: SharedTree) {
|
||||
const [tree, setTree] = useState<
|
||||
TreeApi<SharedPageTreeNode> | null | undefined
|
||||
>(null);
|
||||
const rootElement = useRef<HTMLDivElement>(null);
|
||||
const rootElement = useRef<HTMLDivElement>();
|
||||
const { ref: sizeRef, width, height } = useElementSize();
|
||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||
const { pageSlug } = useParams();
|
||||
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
getShares,
|
||||
updateShare,
|
||||
} from "@/features/share/services/share-service.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useGetSharesQuery(
|
||||
params?: QueryParams,
|
||||
@@ -70,7 +72,7 @@ export function useShareForPageQuery(
|
||||
queryKey: ["share-for-page", pageId],
|
||||
queryFn: () => getShareForPage(pageId),
|
||||
enabled: !!pageId,
|
||||
staleTime: 60 * 1000,
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React, { useEffect } from "react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import * as z from "zod";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||
@@ -10,12 +9,12 @@ import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().min(2).max(100),
|
||||
name: z.string().trim().min(2).max(50),
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(100)
|
||||
.max(50)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9]+$/,
|
||||
"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";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
description: z.string().max(500),
|
||||
name: z.string().min(2).max(50),
|
||||
description: z.string().max(250),
|
||||
slug: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(100)
|
||||
.max(50)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9]+$/,
|
||||
"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 { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
|
||||
export default function SpaceList() {
|
||||
const { t } = useTranslation();
|
||||
@@ -49,10 +48,10 @@ export default function SpaceList() {
|
||||
variant="filled"
|
||||
name={space.name}
|
||||
/>
|
||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{space.name}
|
||||
</AutoTooltipText>
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{space.description}
|
||||
</Text>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { useTranslation } from "react-i18next";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
|
||||
type MemberType = "user" | "group";
|
||||
|
||||
@@ -139,10 +138,10 @@ export default function SpaceMembersList({
|
||||
|
||||
{member.type === "group" && <IconGroupCircle />}
|
||||
|
||||
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 260 }}>
|
||||
<AutoTooltipText fz="sm" fw={500}>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{member?.name}
|
||||
</AutoTooltipText>
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{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 { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
|
||||
interface AllSpacesListProps {
|
||||
spaces: any[];
|
||||
@@ -97,10 +96,10 @@ export default function AllSpacesList({
|
||||
variant="filled"
|
||||
size="md"
|
||||
/>
|
||||
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 350 }}>
|
||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{space.name}
|
||||
</AutoTooltipText>
|
||||
</Text>
|
||||
{space.description && (
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google": "^3.0.9",
|
||||
"@ai-sdk/openai": "^3.0.11",
|
||||
"@ai-sdk/openai-compatible": "^2.0.12",
|
||||
"@ai-sdk/azure": "^2.0.47",
|
||||
"@ai-sdk/google": "^2.0.18",
|
||||
"@ai-sdk/openai": "^2.0.46",
|
||||
"@aws-sdk/client-s3": "3.701.0",
|
||||
"@aws-sdk/lib-storage": "3.701.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.701.0",
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@langchain/core": "1.1.13",
|
||||
"@langchain/textsplitters": "1.0.1",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/common": "^11.1.11",
|
||||
"@nestjs/common": "^11.1.9",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.11",
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "11.0.0",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.1.11",
|
||||
"@nestjs/platform-socket.io": "^11.1.11",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/platform-fastify": "^11.1.9",
|
||||
"@nestjs/platform-socket.io": "^11.1.9",
|
||||
"@nestjs/schedule": "^6.0.1",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.1.11",
|
||||
"@nestjs/websockets": "^11.1.9",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@react-email/components": "0.0.28",
|
||||
"@react-email/render": "1.0.2",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"ai": "^6.0.37",
|
||||
"ai-sdk-ollama": "^3.1.1",
|
||||
"ai": "^5.0.65",
|
||||
"ai-sdk-ollama": "^0.12.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.65.0",
|
||||
"cache-manager": "^6.4.3",
|
||||
"cheerio": "^1.1.2",
|
||||
"cheerio": "^1.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"cookie": "^1.1.1",
|
||||
"fs-extra": "^11.3.3",
|
||||
"happy-dom": "20.1.0",
|
||||
"cookie": "^1.0.2",
|
||||
"fs-extra": "^11.3.0",
|
||||
"happy-dom": "20.0.10",
|
||||
"ioredis": "^5.4.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "^0.28.2",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"kysely-postgres-js": "^3.0.0",
|
||||
"ldapts": "^7.4.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "3.3.11",
|
||||
"nestjs-kysely": "^1.2.0",
|
||||
"nestjs-pino": "^4.5.0",
|
||||
"nodemailer": "^7.0.12",
|
||||
"nodemailer": "^7.0.11",
|
||||
"openid-client": "^5.7.1",
|
||||
"otpauth": "^9.4.1",
|
||||
"otpauth": "^9.4.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfjs-dist": "^5.4.394",
|
||||
"pg": "^8.16.3",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pgvector": "^0.2.1",
|
||||
"postgres": "^3.4.8",
|
||||
"pino-http": "^11.0.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postmark": "^4.0.5",
|
||||
"react": "^18.3.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-filename-ts": "1.0.2",
|
||||
"sharp": "0.34.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"stripe": "^17.5.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"typesense": "^2.1.0",
|
||||
"ws": "^8.19.0",
|
||||
"ws": "^8.18.3",
|
||||
"yauzl": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -120,6 +116,7 @@
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-google-oauth20": "^2.0.16",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
@@ -127,7 +124,7 @@
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"globals": "^15.15.0",
|
||||
"jest": "^29.7.0",
|
||||
"kysely-codegen": "^0.19.0",
|
||||
"kysely-codegen": "^0.17.0",
|
||||
"prettier": "^3.5.1",
|
||||
"react-email": "3.0.2",
|
||||
"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 { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
||||
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
||||
import { LoggerModule } from './common/logger/logger.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
@@ -36,7 +35,6 @@ try {
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule,
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
|
||||
@@ -26,7 +26,7 @@ export class CollaborationGateway {
|
||||
) {
|
||||
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||
|
||||
this.hocuspocus = new Hocuspocus({
|
||||
this.hocuspocus = HocuspocusServer.configure({
|
||||
debounce: 10000,
|
||||
maxDebounce: 45000,
|
||||
unloadImmediately: false,
|
||||
@@ -65,6 +65,6 @@ export class CollaborationGateway {
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
//await this.hocuspocus.destroy();
|
||||
await this.hocuspocus.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { TextAlign } from '@tiptap/extension-text-align';
|
||||
import { TaskList } from '@tiptap/extension-task-list';
|
||||
import { TaskItem } from '@tiptap/extension-task-item';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Superscript } from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import { Youtube } from '@tiptap/extension-youtube';
|
||||
import { TaskList, TaskItem } from '@tiptap/extension-list';
|
||||
import {
|
||||
Heading,
|
||||
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
|
||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||
//import { generateJSON } from '@tiptap/html';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
trailingNode: false,
|
||||
heading: false,
|
||||
}),
|
||||
Heading,
|
||||
@@ -60,6 +59,7 @@ export const tiptapExtensions = [
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
Underline,
|
||||
LinkExtension,
|
||||
Superscript,
|
||||
SubScript,
|
||||
|
||||
@@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension {
|
||||
}
|
||||
|
||||
if (userSpaceRole === SpaceRole.READER) {
|
||||
data.connectionConfig.readOnly = true;
|
||||
data.connection.readOnly = true;
|
||||
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 { HealthModule } from '../../integrations/health/health.module';
|
||||
import { CollaborationController } from './collaboration.controller';
|
||||
import { LoggerModule } from '../../common/logger/logger.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
CollaborationModule,
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
NestFastifyApplication,
|
||||
} from '@nestjs/platform-fastify';
|
||||
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
|
||||
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Logger as PinoLogger } from 'nestjs-pino';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
@@ -17,12 +17,10 @@ async function bootstrap() {
|
||||
maxParamLength: 500,
|
||||
}),
|
||||
{
|
||||
bufferLogs: true,
|
||||
logger: new InternalLogFilter(),
|
||||
},
|
||||
);
|
||||
|
||||
app.useLogger(app.get(PinoLogger));
|
||||
|
||||
app.setGlobalPrefix('api', { exclude: ['/'] });
|
||||
|
||||
app.enableCors();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum EventName {
|
||||
COLLAB_PAGE_UPDATED = 'collab.page.updated',
|
||||
|
||||
PAGE_CREATED = 'page.created',
|
||||
PAGE_UPDATED = 'page.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 =
|
||||
'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
|
||||
}
|
||||
|
||||
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 {
|
||||
OPEN = 'open', // any workspace member can see that it exists and join.
|
||||
PRIVATE = 'private', // only added space users can see
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as path from 'path';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { Readable, Transform } from 'stream';
|
||||
|
||||
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
||||
|
||||
@@ -99,38 +98,3 @@ export function hasLicenseOrEE(opts: {
|
||||
const { licenseKey, plan, isCloud } = opts;
|
||||
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();
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
||||
|
||||
|
||||
if (isProduction && !isDebugMode) {
|
||||
this.allowedLogLevels = ['log', 'error', 'fatal'];
|
||||
} 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';
|
||||
|
||||
export interface PreparedFile {
|
||||
buffer?: Buffer;
|
||||
buffer: Buffer;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileExtension: string;
|
||||
mimeType: string;
|
||||
multiPartFile?: MultipartFile;
|
||||
}
|
||||
|
||||
export async function prepareFile(
|
||||
filePromise: Promise<MultipartFile>,
|
||||
options: { skipBuffer?: boolean } = {},
|
||||
): Promise<PreparedFile> {
|
||||
const file = await filePromise;
|
||||
|
||||
@@ -24,16 +22,10 @@ export async function prepareFile(
|
||||
}
|
||||
|
||||
try {
|
||||
let buffer: Buffer | undefined;
|
||||
let fileSize = 0;
|
||||
|
||||
if (!options.skipBuffer) {
|
||||
buffer = await file.toBuffer();
|
||||
fileSize = buffer.length;
|
||||
}
|
||||
|
||||
const buffer = await file.toBuffer();
|
||||
const sanitizedFilename = sanitizeFileName(file.filename);
|
||||
const fileName = sanitizedFilename.slice(0, 255);
|
||||
const fileSize = buffer.length;
|
||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||
|
||||
return {
|
||||
@@ -42,7 +34,6 @@ export async function prepareFile(
|
||||
fileSize,
|
||||
fileExtension,
|
||||
mimeType: file.mimetype,
|
||||
multiPartFile: file,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import {
|
||||
@@ -27,7 +26,6 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import { createByteCountingStream } from '../../../common/helpers/utils';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentService {
|
||||
@@ -51,9 +49,7 @@ export class AttachmentService {
|
||||
attachmentId?: string;
|
||||
}) {
|
||||
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise, {
|
||||
skipBuffer: true,
|
||||
});
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
|
||||
let isUpdate = false;
|
||||
let attachmentId = null;
|
||||
@@ -85,14 +81,7 @@ export class AttachmentService {
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
|
||||
|
||||
const { stream, getBytesRead } = createByteCountingStream(
|
||||
preparedFile.multiPartFile.file,
|
||||
);
|
||||
|
||||
await this.uploadToDrive(filePath, stream);
|
||||
|
||||
// Update fileSize from the consumed stream
|
||||
preparedFile.fileSize = getBytesRead();
|
||||
await this.uploadToDrive(filePath, preparedFile.buffer);
|
||||
|
||||
let attachment: Attachment = null;
|
||||
try {
|
||||
@@ -153,10 +142,7 @@ export class AttachmentService {
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
validateFileType(preparedFile.fileExtension, validImageExtensions);
|
||||
|
||||
const processedBuffer = await compressAndResizeIcon(
|
||||
preparedFile.buffer,
|
||||
type,
|
||||
);
|
||||
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
|
||||
preparedFile.buffer = processedBuffer;
|
||||
preparedFile.fileSize = processedBuffer.length;
|
||||
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 {
|
||||
await this.storageService.upload(filePath, fileContent);
|
||||
await this.storageService.upload(filePath, fileBuffer);
|
||||
} catch (err) {
|
||||
this.logger.error('Error uploading file to drive:', err);
|
||||
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 SpaceAbilityFactory from './abilities/space-ability.factory';
|
||||
import WorkspaceAbilityFactory from './abilities/workspace-ability.factory';
|
||||
import PageAbilityFactory from './abilities/page-ability.factory';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [WorkspaceAbilityFactory, SpaceAbilityFactory],
|
||||
exports: [WorkspaceAbilityFactory, SpaceAbilityFactory],
|
||||
providers: [WorkspaceAbilityFactory, SpaceAbilityFactory, PageAbilityFactory],
|
||||
exports: [WorkspaceAbilityFactory, SpaceAbilityFactory, PageAbilityFactory],
|
||||
})
|
||||
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,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import {Transform, TransformFnParams} from "class-transformer";
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
|
||||
export class CreateGroupDto {
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
@MaxLength(50)
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
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()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ export type CopyPageMapEntry = {
|
||||
};
|
||||
|
||||
export type ICopyPageAttachment = {
|
||||
newPageId: string,
|
||||
oldPageId: string,
|
||||
oldAttachmentId: string,
|
||||
newAttachmentId: string,
|
||||
newPageId: string;
|
||||
oldPageId: string;
|
||||
oldAttachmentId: 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';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
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 { DuplicatePageDto } from './dto/duplicate-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)
|
||||
@Controller('pages')
|
||||
@@ -44,6 +59,9 @@ export class PageController {
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pageHistoryService: PageHistoryService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAbility: PageAbilityFactory,
|
||||
private readonly pagePermissionService: PagePermissionService,
|
||||
private readonly sharedPagesRepo: SharedPagesRepo,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -61,11 +79,21 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
const pageAbility = await this.pageAbility.createForUser(user, page.id);
|
||||
|
||||
if (pageAbility.cannot(PageCaslAction.Read, PageCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
/*const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
page.spaceId,
|
||||
);
|
||||
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}*/
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -389,4 +417,162 @@ export class PageController {
|
||||
}
|
||||
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 { PageHistoryService } from './services/page-history.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';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||
exports: [PageService, PageHistoryService],
|
||||
providers: [
|
||||
PageService,
|
||||
PageHistoryService,
|
||||
TrashCleanupService,
|
||||
PagePermissionService,
|
||||
SharedPagesRepo,
|
||||
],
|
||||
exports: [PageService, PageHistoryService, PagePermissionService],
|
||||
imports: [StorageModule],
|
||||
})
|
||||
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);
|
||||
} else if (opts.userId && !searchParams.spaceId) {
|
||||
// only search spaces the user is a member of
|
||||
queryResults = queryResults
|
||||
.where(
|
||||
'spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
|
||||
)
|
||||
.where('workspaceId', '=', opts.workspaceId);
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
|
||||
opts.userId,
|
||||
);
|
||||
if (userSpaceIds.length > 0) {
|
||||
queryResults = queryResults
|
||||
.where('spaceId', 'in', userSpaceIds)
|
||||
.where('workspaceId', '=', opts.workspaceId);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
|
||||
// search in shares
|
||||
const shareId = searchParams.shareId;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { validate as isValidUUID } from 'uuid';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { htmlEscape } from '../../common/helpers/html-escaper';
|
||||
|
||||
@Controller('share')
|
||||
export class ShareSeoController {
|
||||
@@ -69,7 +68,7 @@ export class ShareSeoController {
|
||||
return this.sendIndex(indexFilePath, res);
|
||||
}
|
||||
|
||||
const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled');
|
||||
const rawTitle = share.sharedPage.title ?? 'untitled';
|
||||
const metaTitle =
|
||||
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle;
|
||||
|
||||
|
||||
@@ -123,82 +123,80 @@ export class ShareService {
|
||||
.withRecursive('page_hierarchy', (cte) =>
|
||||
cte
|
||||
.selectFrom('pages')
|
||||
.leftJoin('shares', 'shares.pageId', 'pages.id')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'id',
|
||||
'slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.parentPageId',
|
||||
'parentPageId',
|
||||
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('pages.deletedAt', 'is', null)
|
||||
.unionAll(
|
||||
(union) =>
|
||||
union
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
||||
.leftJoin('shares as s', 's.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.parentPageId',
|
||||
sql`ph.level + 1`.as('level'),
|
||||
's.id as shareId',
|
||||
'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
|
||||
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((union) =>
|
||||
union
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.parentPageId',
|
||||
// Increase the level by 1 for each ancestor.
|
||||
sql`ph.level + 1`.as('level'),
|
||||
])
|
||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
||||
.where('p.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.selectAll()
|
||||
.where('shareId', 'is not', null)
|
||||
.limit(1)
|
||||
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id')
|
||||
.select([
|
||||
'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();
|
||||
|
||||
if (!share || share.workspaceId !== workspaceId) {
|
||||
if (!share || share.workspaceId != workspaceId) {
|
||||
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 {
|
||||
id: share.shareId,
|
||||
key: share.shareKey,
|
||||
id: share.id,
|
||||
key: share.key,
|
||||
includeSubPages: share.includeSubPages,
|
||||
searchIndexing: share.searchIndexing,
|
||||
pageId: share.id,
|
||||
pageId: share.pageId,
|
||||
creatorId: share.creatorId,
|
||||
spaceId: share.spaceId,
|
||||
workspaceId: share.workspaceId,
|
||||
createdAt: share.createdAt,
|
||||
level: share.level,
|
||||
sharedPage: {
|
||||
id: share.id,
|
||||
slugId: share.slugId,
|
||||
title: share.title,
|
||||
icon: share.icon,
|
||||
id: share.sharedPageId,
|
||||
slugId: share.sharedPageSlugId,
|
||||
title: share.sharedPageTitle,
|
||||
icon: share.sharedPageIcon,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import {Transform, TransformFnParams} from "class-transformer";
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
|
||||
export class CreateSpaceDto {
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
@MaxLength(50)
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
name: string;
|
||||
@@ -19,7 +19,7 @@ export class CreateSpaceDto {
|
||||
description?: string;
|
||||
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
@MaxLength(50)
|
||||
@IsAlphanumeric()
|
||||
slug: string;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,9 @@ export class UserService {
|
||||
);
|
||||
|
||||
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)) {
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
|
||||
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 { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.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 { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
import { normalizePostgresUrl } from '../common/helpers';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission-repo.service';
|
||||
|
||||
// https://github.com/brianc/node-postgres/issues/811
|
||||
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -36,30 +38,26 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
imports: [],
|
||||
inject: [EnvironmentService],
|
||||
useFactory: (environmentService: EnvironmentService) => ({
|
||||
dialect: new PostgresJSDialect({
|
||||
postgres: postgres(
|
||||
normalizePostgresUrl(environmentService.getDatabaseURL()),
|
||||
{
|
||||
max: environmentService.getDatabaseMaxPool(),
|
||||
onnotice: () => {},
|
||||
types: {
|
||||
bigint: {
|
||||
to: 20,
|
||||
from: [20, 1700],
|
||||
serialize: (value: number) => value.toString(),
|
||||
parse: (value: string) => Number.parseInt(value),
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
dialect: new PostgresDialect({
|
||||
pool: new Pool({
|
||||
connectionString: environmentService.getDatabaseURL(),
|
||||
max: environmentService.getDatabaseMaxPool(),
|
||||
}).on('error', (err) => {
|
||||
console.error('Database error:', err.message);
|
||||
}),
|
||||
}),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
log: (event: LogEvent) => {
|
||||
if (environmentService.getNodeEnv() !== 'development') return;
|
||||
const logger = new Logger(DatabaseModule.name);
|
||||
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
|
||||
logger.debug(event.query.sql);
|
||||
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
|
||||
if (event.level) {
|
||||
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
|
||||
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,
|
||||
ShareRepo,
|
||||
PageListener,
|
||||
PagePermissionRepo,
|
||||
],
|
||||
exports: [
|
||||
WorkspaceRepo,
|
||||
@@ -96,6 +95,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
UserTokenRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
PagePermissionRepo,
|
||||
],
|
||||
})
|
||||
export class DatabaseModule
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import * as path from 'path';
|
||||
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 * as dotenv from 'dotenv';
|
||||
import { envPath, normalizePostgresUrl } from '../common/helpers';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { envPath } from '../common/helpers/utils';
|
||||
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
const migrationFolder = path.join(__dirname, './migrations');
|
||||
|
||||
const db = new Kysely<any>({
|
||||
dialect: new PostgresJSDialect({
|
||||
postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL)),
|
||||
dialect: new PostgresDialect({
|
||||
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> {
|
||||
await db.schema
|
||||
.alterTable('pages')
|
||||
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo("{}"))
|
||||
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}'))
|
||||
.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()
|
||||
@IsString()
|
||||
query: string;
|
||||
query?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
adminView: boolean;
|
||||
adminView?: boolean;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,10 @@ export class CommentRepo {
|
||||
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
|
||||
.selectFrom('comments')
|
||||
.select((eb) => eb.fn.count('id').as('count'))
|
||||
|
||||
@@ -57,7 +57,11 @@ export class GroupUserRepo {
|
||||
|
||||
if (pagination.query) {
|
||||
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) {
|
||||
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)`,
|
||||
'ilike',
|
||||
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