mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 207c119792 | |||
| 4683a85a8b | |||
| 74e915546b | |||
| 3523600f40 | |||
| 6ccb2bb872 | |||
| 0245a183e1 | |||
| de5f71894a | |||
| 351b075ebb | |||
| 1ca7d42203 | |||
| 1e441560f6 | |||
| 54775f537d | |||
| 5dbf0027bd | |||
| 5588ec34fb | |||
| 55b8128829 | |||
| aa6a046aa6 | |||
| 657fdf8cb7 | |||
| 98f71c95fe | |||
| efb0a9317b | |||
| 063ea99b66 | |||
| aa143ad79c | |||
| 918f4508d2 | |||
| 5cd0ba6902 | |||
| a1260188ae | |||
| bdf02f593d | |||
| e24bf5ed57 | |||
| f3f74c591f | |||
| 5f966a2d89 | |||
| bcb004af21 | |||
| ac675e7d74 | |||
| bf89eff5e7 | |||
| 183787fa0c | |||
| 15aa04a5f7 | |||
| 79343a5d52 | |||
| 61e252918e | |||
| e98fa7f69a | |||
| 6d148a35eb | |||
| 0bbc1c35de | |||
| 47097969a0 | |||
| 13f529e064 | |||
| 8fc8422fbc |
+3
-2
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
dist
|
||||
data
|
||||
/data
|
||||
.env*
|
||||
.nx
|
||||
|
||||
+7
-1
@@ -46,4 +46,10 @@ DRAWIO_URL=
|
||||
DISABLE_TELEMETRY=false
|
||||
|
||||
# Enable debug logging in production (default: false)
|
||||
DEBUG_MODE=false
|
||||
DEBUG_MODE=false
|
||||
|
||||
# Log database queries
|
||||
DEBUG_DB=false
|
||||
|
||||
# Log http requests
|
||||
LOG_HTTP=false
|
||||
|
||||
+3
-3
@@ -1,13 +1,14 @@
|
||||
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
|
||||
|
||||
@@ -31,12 +32,11 @@ 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
|
||||
|
||||
+20
-23
@@ -10,52 +10,49 @@
|
||||
"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-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",
|
||||
"@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",
|
||||
"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.14.0",
|
||||
"i18next-http-backend": "^2.6.1",
|
||||
"jotai": "^2.12.5",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-http-backend": "^2.7.3",
|
||||
"jotai": "^2.16.2",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "0.16.22",
|
||||
"katex": "0.16.27",
|
||||
"lowlight": "^3.3.0",
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"mermaid": "^11.11.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.15",
|
||||
"react-clear-modal": "^2.0.17",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^1.0.1",
|
||||
"react-drawio": "^1.0.7",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-router-dom": "^7.0.1",
|
||||
"semver": "^7.7.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"semver": "^7.7.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
|
||||
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
|
||||
"Confirm": "Bestätigen",
|
||||
"Copy as Markdown": "Als Markdown kopieren",
|
||||
"Copy link": "Link kopieren",
|
||||
"Create": "Erstellen",
|
||||
"Create group": "Gruppe erstellen",
|
||||
@@ -234,7 +235,9 @@
|
||||
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
||||
"Invite link": "Einladungslink",
|
||||
"Copy": "Kopieren",
|
||||
"Copy to space": "In Raum kopieren",
|
||||
"Copied": "Kopiert",
|
||||
"Duplicate": "Duplizieren",
|
||||
"Select a user": "Benutzer auswählen",
|
||||
"Select a group": "Gruppe auswählen",
|
||||
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
|
||||
@@ -251,6 +254,7 @@
|
||||
"Export failed:": "Export fehlgeschlagen:",
|
||||
"export error": "Exportfehler",
|
||||
"Export page": "Seite exportieren",
|
||||
"Export successful": "Export erfolgreich",
|
||||
"Export space": "Bereich exportieren",
|
||||
"Export {{type}}": "Exportiere {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
|
||||
@@ -326,6 +330,8 @@
|
||||
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
|
||||
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
|
||||
"Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
|
||||
"Uploading {{name}}": "Lade {{name}} hoch",
|
||||
"Uploading file": "Datei wird hochgeladen",
|
||||
"Table": "Tabelle",
|
||||
"Insert a table.": "Tabelle einfügen.",
|
||||
"Insert collapsible block.": "Einklappbaren Block einfügen.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
||||
"Choose your preferred page width.": "Choose your preferred page width.",
|
||||
"Confirm": "Confirm",
|
||||
"Copy as Markdown": "Copy as Markdown",
|
||||
"Copy link": "Copy link",
|
||||
"Create": "Create",
|
||||
"Create group": "Create group",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Export failed:",
|
||||
"export error": "export error",
|
||||
"Export page": "Export page",
|
||||
"Export successful": "Export successful",
|
||||
"Export space": "Export space",
|
||||
"Export {{type}}": "Export {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
||||
@@ -328,6 +330,8 @@
|
||||
"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.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
|
||||
"Choose your preferred page width.": "Elige el ancho de página que prefieras.",
|
||||
"Confirm": "Confirmar",
|
||||
"Copy as Markdown": "Copiar como Markdown",
|
||||
"Copy link": "Copiar enlace",
|
||||
"Create": "Crear",
|
||||
"Create group": "Crear grupo",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Exportación fallida:",
|
||||
"export error": "error de exportación",
|
||||
"Export page": "Exportar página",
|
||||
"Export successful": "Exportación exitosa",
|
||||
"Export space": "Exportar espacio",
|
||||
"Export {{type}}": "Exportar {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
|
||||
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
|
||||
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
|
||||
"Uploading {{name}}": "Subiendo {{name}}",
|
||||
"Uploading file": "Subiendo archivo",
|
||||
"Table": "Tabla",
|
||||
"Insert a table.": "Insertar una tabla.",
|
||||
"Insert collapsible block.": "Insertar bloque desplegable.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.",
|
||||
"Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
|
||||
"Confirm": "Confirmer",
|
||||
"Copy as Markdown": "Copier comme Markdown",
|
||||
"Copy link": "Copier le lien",
|
||||
"Create": "Créer",
|
||||
"Create group": "Créer groupe",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Échec de l'exportation :",
|
||||
"export error": "exporter l'erreur",
|
||||
"Export page": "Exporter la page",
|
||||
"Export successful": "Exportation réussie",
|
||||
"Export space": "Exporter l'espace",
|
||||
"Export {{type}}": "Exporter {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
|
||||
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
|
||||
"Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
|
||||
"Uploading {{name}}": "Téléchargement de {{name}}",
|
||||
"Uploading file": "Téléchargement du fichier",
|
||||
"Table": "Tableau",
|
||||
"Insert a table.": "Insérez un tableau.",
|
||||
"Insert collapsible block.": "Insérer un bloc repliable.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
|
||||
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
|
||||
"Confirm": "Conferma",
|
||||
"Copy as Markdown": "Copia come Markdown",
|
||||
"Copy link": "Copia link",
|
||||
"Create": "Crea",
|
||||
"Create group": "Crea gruppo",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Esportazione fallita:",
|
||||
"export error": "errore di esportazione",
|
||||
"Export page": "Esporta pagina",
|
||||
"Export successful": "Esportazione riuscita",
|
||||
"Export space": "Esporta spazio",
|
||||
"Export {{type}}": "Esporta {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
|
||||
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
|
||||
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
|
||||
"Uploading {{name}}": "Caricamento di {{name}}",
|
||||
"Uploading file": "Caricamento file",
|
||||
"Table": "Tabella",
|
||||
"Insert a table.": "Inserisci una tabella.",
|
||||
"Insert collapsible block.": "Inserisci blocco comprimibile.",
|
||||
|
||||
@@ -13,22 +13,23 @@
|
||||
"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 as Markdown": "Markdownとしてコピー",
|
||||
"Copy link": "リンクをコピー",
|
||||
"Create": "新規作成",
|
||||
"Create group": "グループを作成",
|
||||
@@ -40,24 +41,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 +82,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 +114,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 +144,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": "Slug (URL用文字列)",
|
||||
"Sign Up": "新規登録",
|
||||
"Slug": "スラッグ(URL識別子)",
|
||||
"Space": "スペース",
|
||||
"Space description": "スペース説明",
|
||||
"Space menu": "スペースメニュー",
|
||||
"Space name": "スペース名",
|
||||
"Space settings": "スペース設定",
|
||||
"Space slug": "スペースのSlug (URL用文字列)",
|
||||
"Space 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 +240,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": "添付ファイルを含める",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "エクスポートに失敗しました:",
|
||||
"export error": "エクスポートエラー",
|
||||
"Export page": "エクスポートページ",
|
||||
"Export successful": "エクスポート成功",
|
||||
"Export space": "エクスポートスペース",
|
||||
"Export {{type}}": "{{type}}をエクスポート",
|
||||
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
|
||||
@@ -273,12 +275,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 +317,24 @@
|
||||
"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.": "デバイスからファイルをアップロードします",
|
||||
"Uploading {{name}}": "{{name}} をアップロード中",
|
||||
"Uploading file": "ファイルをアップロード中",
|
||||
"Table": "テーブル",
|
||||
"Insert a table.": "表を挿入します。",
|
||||
"Insert collapsible block.": "折りたたみ可能なブロックを挿入します。",
|
||||
"Insert a table.": "テーブルを挿入します",
|
||||
"Insert collapsible block.": "折りたたみブロックを挿入します",
|
||||
"Video": "動画",
|
||||
"Divider": "区切り線",
|
||||
"Quote": "引用",
|
||||
@@ -338,16 +342,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": "Drawioの図を挿入してデザインします",
|
||||
"Insert current date": "今日の日付を挿入します",
|
||||
"Draw and sketch excalidraw diagrams": "Excalidrawの図を埋め込みます",
|
||||
"Mermaid diagram": "Mermaid ダイアグラム",
|
||||
"Insert mermaid diagram": "Mermaid ダイアグラムを挿入します",
|
||||
"Insert and design Drawio diagrams": "Draw.io 図を挿入・編集します",
|
||||
"Insert current date": "現在の日付を挿入します",
|
||||
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
|
||||
"Multiple": "複数",
|
||||
"Heading {{level}}": "見出し {{level}}",
|
||||
"Toggle title": "タイトルの表示/非表示を切り替える",
|
||||
@@ -357,29 +361,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 +402,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 +423,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.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
|
||||
"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.": "認証アプリにアクセスできない場合、バックアップコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
|
||||
"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 +453,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.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
|
||||
"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回のみ使用可能です",
|
||||
"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.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です",
|
||||
"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 +515,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 +536,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 +554,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 +566,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質問は利用できません",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
|
||||
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
|
||||
"Confirm": "확인",
|
||||
"Copy as Markdown": "Markdown으로 복사",
|
||||
"Copy link": "링크 복사",
|
||||
"Create": "생성",
|
||||
"Create group": "팀 생성",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "내보내기 실패:",
|
||||
"export error": "내보내기 오류",
|
||||
"Export page": "페이지 내보내기",
|
||||
"Export successful": "내보내기 성공",
|
||||
"Export space": "Space 내보내기",
|
||||
"Export {{type}}": "{{type}} 내보내기",
|
||||
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
|
||||
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
|
||||
"Upload any file from your device.": "기기에서 파일을 업로드하세요.",
|
||||
"Uploading {{name}}": "{{name}} 업로드 중",
|
||||
"Uploading file": "파일 업로드 중",
|
||||
"Table": "테이블",
|
||||
"Insert a table.": "테이블 삽입.",
|
||||
"Insert collapsible block.": "접을 수 있는 블록 삽입.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
|
||||
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
|
||||
"Confirm": "Bevestig",
|
||||
"Copy as Markdown": "Kopiëren als Markdown",
|
||||
"Copy link": "Link kopiëren",
|
||||
"Create": "Aanmaken",
|
||||
"Create group": "Groep aanmaken",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Exporteren mislukt:",
|
||||
"export error": "Exporteer fout",
|
||||
"Export page": "Exporteer pagina",
|
||||
"Export successful": "Export succesvol",
|
||||
"Export space": "Exporteer ruimte",
|
||||
"Export {{type}}": "Exporteer {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
|
||||
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
|
||||
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
|
||||
"Uploading {{name}}": "Uploaden {{name}}",
|
||||
"Uploading file": "Bestand uploaden",
|
||||
"Table": "Tabel",
|
||||
"Insert a table.": "Voeg een tabel in.",
|
||||
"Insert collapsible block.": "Inklapbaar blok invoegen.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Escolha o idioma da interface.",
|
||||
"Choose your preferred page width.": "Escolha a largura preferida da página.",
|
||||
"Confirm": "Confirmar",
|
||||
"Copy as Markdown": "Copiar como Markdown",
|
||||
"Copy link": "Copiar link",
|
||||
"Create": "Criar",
|
||||
"Create group": "Criar grupo",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Falha ao exportar:",
|
||||
"export error": "erro de exportação",
|
||||
"Export page": "Exportar página",
|
||||
"Export successful": "Exportação bem-sucedida",
|
||||
"Export space": "Exportar espaço",
|
||||
"Export {{type}}": "Exportar para {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
|
||||
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
|
||||
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
|
||||
"Uploading {{name}}": "Enviando {{name}}",
|
||||
"Uploading file": "Enviando arquivo",
|
||||
"Table": "Tabela",
|
||||
"Insert a table.": "Insira uma tabela.",
|
||||
"Insert collapsible block.": "Insira um bloco colapsável.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
|
||||
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
|
||||
"Confirm": "Подтвердить",
|
||||
"Copy as Markdown": "Копировать как Markdown",
|
||||
"Copy link": "Копировать ссылку",
|
||||
"Create": "Создать",
|
||||
"Create group": "Создать группу",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Экспортирование не удалось:",
|
||||
"export error": "ошибка экспорта",
|
||||
"Export page": "Экспорт страницы",
|
||||
"Export successful": "Экспорт выполнен успешно",
|
||||
"Export space": "Экспорт пространства",
|
||||
"Export {{type}}": "Экспорт {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
|
||||
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
|
||||
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
|
||||
"Uploading {{name}}": "Загрузка {{name}}",
|
||||
"Uploading file": "Загрузка файла",
|
||||
"Table": "Таблица",
|
||||
"Insert a table.": "Вставить таблицу.",
|
||||
"Insert collapsible block.": "Вставить сворачиваемый блок.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
||||
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
||||
"Confirm": "Підтвердити",
|
||||
"Copy as Markdown": "Скопіювати як Markdown",
|
||||
"Copy link": "Копіювати посилання",
|
||||
"Create": "Створити",
|
||||
"Create group": "Створити групу",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Експортування не вдалося:",
|
||||
"export error": "помилка експорту",
|
||||
"Export page": "Експорт сторінки",
|
||||
"Export successful": "Експорт виконано успішно",
|
||||
"Export space": "Експорт простору",
|
||||
"Export {{type}}": "Експорт {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
||||
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
||||
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
||||
"Uploading {{name}}": "Завантаження {{name}}",
|
||||
"Uploading file": "Завантаження файлу",
|
||||
"Table": "Таблиця",
|
||||
"Insert a table.": "Вставити таблицю.",
|
||||
"Insert collapsible block.": "Вставити блок, що згортається.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "选择您喜欢的界面语言。",
|
||||
"Choose your preferred page width.": "选择您喜欢的页面宽度。",
|
||||
"Confirm": "确认",
|
||||
"Copy as Markdown": "复制为Markdown",
|
||||
"Copy link": "复制链接",
|
||||
"Create": "创建",
|
||||
"Create group": "创建群组",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "导出失败:",
|
||||
"export error": "导出出错",
|
||||
"Export page": "导出页面",
|
||||
"Export successful": "导出成功",
|
||||
"Export space": "导出空间",
|
||||
"Export {{type}}": "导出为 {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "从设备上传任何图像",
|
||||
"Upload any video from your device.": "从设备上传任何视频",
|
||||
"Upload any file from your device.": "从设备上传任何文件",
|
||||
"Uploading {{name}}": "正在上传{{name}}",
|
||||
"Uploading file": "正在上传文件",
|
||||
"Table": "表格",
|
||||
"Insert a table.": "插入一个表格",
|
||||
"Insert collapsible block.": "插入一个折叠块",
|
||||
|
||||
@@ -30,9 +30,11 @@ export default function ExportModal({
|
||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
if (type === "page") {
|
||||
await exportPage({
|
||||
@@ -45,6 +47,9 @@ export default function ExportModal({
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Export successful"),
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
@@ -52,6 +57,8 @@ export default function ExportModal({
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,7 +143,7 @@ export default function ExportModal({
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleExport}>{t("Export")}</Button>
|
||||
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
|
||||
@@ -5,26 +5,27 @@ 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) {
|
||||
@@ -44,8 +45,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>
|
||||
)}
|
||||
|
||||
@@ -58,18 +59,23 @@ export default function RecentChanges({spaceId}: Props) {
|
||||
{!spaceId && (
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color="blue"
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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,11 +1,13 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
|
||||
import { Group, Text, Paper, ActionIcon, Loader } 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();
|
||||
@@ -20,26 +22,28 @@ export default function AttachmentView(props: NodeViewProps) {
|
||||
wrap="nowrap"
|
||||
h={25}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<IconPaperclip size={20} />
|
||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
|
||||
{url ? (
|
||||
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<Text component="span" size="md" truncate="end">
|
||||
{name}
|
||||
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
|
||||
{url ? name : t("Uploading {{name}}", { name })}
|
||||
</Text>
|
||||
|
||||
<Text component="span" size="sm" c="dimmed" inline>
|
||||
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{formatBytes(size)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{selected || hovered ? (
|
||||
{url && (selected || hovered) && (
|
||||
<a href={getFileUrl(url)} target="_blank">
|
||||
<ActionIcon variant="default" aria-label="download file">
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import {
|
||||
BubbleMenu,
|
||||
BubbleMenuProps,
|
||||
isNodeSelection,
|
||||
useEditor,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
||||
import { isNodeSelection, useEditorState } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
IconBold,
|
||||
@@ -38,7 +34,7 @@ export interface BubbleMenuItem {
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
editor: Editor | null;
|
||||
};
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
@@ -133,14 +129,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
}
|
||||
return isTextSelected(editor);
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: "transform 0.15s ease-out",
|
||||
onCreate: (instance) => {
|
||||
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
},
|
||||
options: {
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
onHide: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
@@ -156,7 +147,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<BubbleMenu {...bubbleMenuProps}>
|
||||
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
||||
<div className={classes.bubbleMenu}>
|
||||
<NodeSelector
|
||||
editor={props.editor}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
@@ -53,17 +49,26 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
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;
|
||||
return dom.getBoundingClientRect();
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const setCalloutType = useCallback(
|
||||
@@ -112,14 +117,12 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`callout-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 10],
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "bottom",
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
// offset: 233, // // offset: [0, 10],
|
||||
// zIndex: 99,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -90,6 +90,7 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
node.textContent.length > 0
|
||||
}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<NodeViewContent as="code" className={`language-${language}`} />
|
||||
</pre>
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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 = (
|
||||
view: EditorView,
|
||||
editor: Editor,
|
||||
event: ClipboardEvent,
|
||||
pageId: string,
|
||||
creatorId?: string,
|
||||
@@ -18,7 +17,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 } = view.state.selection;
|
||||
const { from: pos, empty } = editor.state.selection;
|
||||
const match = INTERNAL_LINK_REGEX.exec(url);
|
||||
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
||||
|
||||
@@ -34,19 +33,27 @@ 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, view, pos, creatorId, anchorId);
|
||||
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,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.clipboardData?.files.length) {
|
||||
event.preventDefault();
|
||||
for (const file of event.clipboardData.files) {
|
||||
const pos = view.state.selection.from;
|
||||
uploadImageAction(file, view, pos, pageId);
|
||||
uploadVideoAction(file, view, pos, pageId);
|
||||
uploadAttachmentAction(file, view, pos, pageId);
|
||||
const pos = editor.state.selection.from;
|
||||
uploadImageAction(file, editor, pos, pageId);
|
||||
uploadVideoAction(file, editor, pos, pageId);
|
||||
uploadAttachmentAction(file, editor, pos, pageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -54,7 +61,7 @@ export const handlePaste = (
|
||||
};
|
||||
|
||||
export const handleFileDrop = (
|
||||
view: EditorView,
|
||||
editor: Editor,
|
||||
event: DragEvent,
|
||||
moved: boolean,
|
||||
pageId: string,
|
||||
@@ -63,14 +70,14 @@ export const handleFileDrop = (
|
||||
event.preventDefault();
|
||||
|
||||
for (const file of event.dataTransfer.files) {
|
||||
const coordinates = view.posAtCoords({
|
||||
const coordinates = editor.view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { sticky } from "tippy.js";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
@@ -40,17 +35,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "drawio";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
@@ -65,15 +69,11 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`drawio-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -66,6 +66,7 @@ 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,16 +1,41 @@
|
||||
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: any | 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;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
onBeforeStart: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: DOMRect;
|
||||
clientRect: () => DOMRect;
|
||||
}) => {
|
||||
init({
|
||||
data: async () => (await import("@emoji-mart/data")).default,
|
||||
@@ -25,51 +50,61 @@ const renderEmojiItems = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom",
|
||||
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)`,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
onStart: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: DOMRect;
|
||||
clientRect: () => DOMRect;
|
||||
}) => {
|
||||
component?.updateProps({...props, isLoading: false});
|
||||
component?.updateProps({ ...props, isLoading: false });
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
if (props.clientRect) {
|
||||
getReferenceClientRect = props.clientRect;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onUpdate: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: DOMRect;
|
||||
clientRect: () => DOMRect;
|
||||
}) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
if (props.clientRect) {
|
||||
getReferenceClientRect = props.clientRect;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
component?.destroy()
|
||||
destroy();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -78,13 +113,7 @@ const renderEmojiItems = () => {
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
if (popup && !popup[0]?.state.isDestroyed) {
|
||||
popup[0]?.destroy();
|
||||
}
|
||||
|
||||
if (component) {
|
||||
component?.destroy();
|
||||
}
|
||||
destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { sticky } from "tippy.js";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
@@ -42,17 +37,26 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
@@ -65,17 +69,13 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`excalidraw-menu}`}
|
||||
pluginKey={`excalidraw-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -98,6 +98,7 @@ 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,11 +1,6 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { 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,
|
||||
@@ -22,16 +17,6 @@ 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,
|
||||
@@ -52,17 +37,37 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
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 { 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;
|
||||
return dom.getBoundingClientRect();
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const alignImageLeft = useCallback(() => {
|
||||
@@ -105,15 +110,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`image-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.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,30 +1,70 @@
|
||||
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 { node, selected } = props;
|
||||
const { src, width, align, title } = node.attrs;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, selected } = props;
|
||||
const { src, width, align, title, aspectRatio, placeholder } = 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>
|
||||
<Image
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||
/>
|
||||
<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>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
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);
|
||||
@@ -59,18 +60,15 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`link-menu}`}
|
||||
pluginKey={`link-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
appendTo: () => {
|
||||
return appendTo?.current;
|
||||
},
|
||||
onHidden: () => {
|
||||
options={{
|
||||
onHide: () => {
|
||||
setShowEdit(false);
|
||||
},
|
||||
placement: "bottom",
|
||||
offset: [0, 5],
|
||||
zIndex: 101,
|
||||
offset: 5,
|
||||
// zIndex: 101,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -106,6 +106,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
|
||||
setRenderItems(items);
|
||||
// update editor storage
|
||||
//@ts-ignore
|
||||
props.editor.storage.mentionItems = items;
|
||||
}
|
||||
}, [suggestion, isLoading]);
|
||||
@@ -163,7 +164,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
|
||||
const enterHandler = () => {
|
||||
if (!renderItems.length) return;
|
||||
if (renderItems[selectedIndex].entityType !== "header") {
|
||||
if (renderItems[selectedIndex]?.entityType !== "header") {
|
||||
selectItem(selectedIndex);
|
||||
}
|
||||
};
|
||||
@@ -203,7 +204,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
parentPageId: page.id || null,
|
||||
title: title
|
||||
};
|
||||
|
||||
|
||||
let createdPage: IPage;
|
||||
try {
|
||||
createdPage = await createPageMutation.mutateAsync(payload);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||
import tippy from "tippy.js";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
|
||||
|
||||
function getWhitespaceCount(query: string) {
|
||||
@@ -9,16 +15,27 @@ function getWhitespaceCount(query: string) {
|
||||
|
||||
const mentionRenderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | 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;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -37,75 +54,95 @@ const mentionRenderItems = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
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",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
onUpdate: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: DOMRect;
|
||||
clientRect: () => DOMRect;
|
||||
query: string;
|
||||
}) => {
|
||||
// query must not start with a whitespace
|
||||
if (props.query.charAt(0) === ' '){
|
||||
component?.destroy();
|
||||
if (props.query.charAt(0) === " ") {
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// only update component if popup is not destroyed
|
||||
if (!popup?.[0].state.isDestroyed) {
|
||||
component?.updateProps(props);
|
||||
if (component) {
|
||||
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 &&
|
||||
props.editor.storage.mentionItems.length === 0
|
||||
whitespaceCount > 4 &&
|
||||
//@ts-ignore
|
||||
props.editor.storage.mentionItems.length === 1
|
||||
) {
|
||||
popup?.[0]?.destroy();
|
||||
component?.destroy();
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
// fallback exit
|
||||
if (whitespaceCount > 7) {
|
||||
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" && !popup?.[0].state.isShown)
|
||||
) {
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
return false;
|
||||
}
|
||||
if (props.event.key === "Escape") {
|
||||
destroy();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (props.event.key === "Enter" && !component) {
|
||||
destroy();
|
||||
return false;
|
||||
}
|
||||
|
||||
return (component?.ref as any)?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
if (popup && !popup?.[0].state.isDestroyed) {
|
||||
popup[0].destroy();
|
||||
}
|
||||
|
||||
if (component) {
|
||||
component.destroy();
|
||||
}
|
||||
destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate, 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 } = useParams();
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: page,
|
||||
isLoading,
|
||||
@@ -23,6 +25,20 @@ 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,
|
||||
@@ -45,6 +61,7 @@ export default function MentionView(props: NodeViewProps) {
|
||||
to={
|
||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
|
||||
}
|
||||
onClick={handleClick}
|
||||
underline="never"
|
||||
className={classes.pageMentionLink}
|
||||
>
|
||||
|
||||
+2
@@ -73,6 +73,8 @@ 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,6 +161,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
@@ -173,9 +174,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadImageAction(file, editor.view, pos, pageId);
|
||||
|
||||
uploadImageAction(file, editor, pos, pageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
@@ -188,6 +193,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
@@ -195,12 +201,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "video/*";
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadVideoAction(file, editor.view, pos, pageId);
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
|
||||
uploadVideoAction(file, editor, pos, pageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
@@ -213,6 +225,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
@@ -220,12 +233,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "";
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadAttachmentAction(file, editor.view, pos, pageId, true);
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
|
||||
uploadAttachmentAction(file, editor, pos, pageId, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||
import CommandList from "@/features/editor/components/slash-menu/command-list";
|
||||
import tippy from "tippy.js";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | 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`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onStart: (props: {
|
||||
@@ -21,15 +46,29 @@ const renderItems = () => {
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
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
|
||||
);
|
||||
},
|
||||
onUpdate: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
@@ -41,14 +80,15 @@ const renderItems = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
// @ts-ignore
|
||||
getReferenceClientRect = props.clientRect;
|
||||
updatePosition();
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
if (popup) {
|
||||
popup.style.display = "none";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -57,12 +97,19 @@ const renderItems = () => {
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
if (popup && !popup[0].state.isDestroyed) {
|
||||
popup[0].destroy();
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
}
|
||||
|
||||
if (popup) {
|
||||
popup.remove();
|
||||
popup = null;
|
||||
}
|
||||
|
||||
if (component) {
|
||||
component.destroy();
|
||||
component = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
posToDOMRect,
|
||||
findParentNode,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
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;
|
||||
@@ -33,7 +29,7 @@ export const SubpagesMenu = React.memo(
|
||||
|
||||
return editor.isActive("subpages");
|
||||
},
|
||||
[editor],
|
||||
[editor]
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
@@ -62,18 +58,8 @@ 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")}>
|
||||
@@ -89,7 +75,7 @@ export const SubpagesMenu = React.memo(
|
||||
</Tooltip>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default SubpagesMenu;
|
||||
|
||||
@@ -19,6 +19,7 @@ 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,6 +1,4 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
@@ -17,6 +15,7 @@ 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 => {
|
||||
@@ -29,7 +28,7 @@ export const TableCellMenu = React.memo(
|
||||
|
||||
return isCellSelection(state.selection);
|
||||
},
|
||||
[editor],
|
||||
[editor]
|
||||
);
|
||||
|
||||
const mergeCells = useCallback(() => {
|
||||
@@ -53,23 +52,27 @@ export const TableCellMenu = React.memo(
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
pluginKey="table-cell-menu"
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
appendTo: () => {
|
||||
return appendTo?.current;
|
||||
appendTo={() => {
|
||||
return appendTo?.current;
|
||||
}}
|
||||
ref={(element) => {
|
||||
element.style.zIndex = "99";
|
||||
}}
|
||||
options={{
|
||||
offset: {
|
||||
mainAxis: 15,
|
||||
},
|
||||
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}
|
||||
@@ -125,9 +128,9 @@ export const TableCellMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</BaseBubbleMenu>
|
||||
</BubbleMenu>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default TableCellMenu;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
posToDOMRect,
|
||||
findParentNode,
|
||||
} from "@tiptap/react";
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
@@ -17,9 +12,12 @@ import {
|
||||
IconColumnRemove,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconRowRemove, IconTableColumn, IconTableRow,
|
||||
IconRowRemove,
|
||||
IconTableColumn,
|
||||
IconTableRow,
|
||||
IconTrashX,
|
||||
} from '@tabler/icons-react';
|
||||
} from "@tabler/icons-react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -34,20 +32,28 @@ export const TableMenu = React.memo(
|
||||
|
||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||
},
|
||||
[editor],
|
||||
[editor]
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const getReferencedVirtualElement = 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;
|
||||
return dom.getBoundingClientRect();
|
||||
const rect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => rect,
|
||||
getClientRects: () => [rect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const rect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => rect,
|
||||
getClientRects: () => [rect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderColumn = useCallback(() => {
|
||||
@@ -87,42 +93,33 @@ export const TableMenu = React.memo(
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
pluginKey="table-menu"
|
||||
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 },
|
||||
},
|
||||
},
|
||||
],
|
||||
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,
|
||||
},
|
||||
}}
|
||||
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"
|
||||
@@ -188,8 +185,7 @@ 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"
|
||||
@@ -200,8 +196,7 @@ 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"
|
||||
@@ -224,9 +219,9 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</BaseBubbleMenu>
|
||||
</BubbleMenu>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default TableMenu;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { 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,
|
||||
@@ -22,16 +17,6 @@ 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,
|
||||
@@ -52,17 +37,37 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
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 { 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;
|
||||
return dom.getBoundingClientRect();
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const alignVideoLeft = useCallback(() => {
|
||||
@@ -105,15 +110,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`video-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.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,29 +1,75 @@
|
||||
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 { node, selected } = props;
|
||||
const { src, width, align } = node.attrs;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, selected } = props;
|
||||
const { src, width, align, aspectRatio, placeholder } = 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>
|
||||
<video
|
||||
preload="metadata"
|
||||
width={width}
|
||||
controls
|
||||
src={getFileUrl(src)}
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
<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>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
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 { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
||||
import { Superscript } from "@tiptap/extension-superscript";
|
||||
import SubScript from "@tiptap/extension-subscript";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
@@ -15,7 +11,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 { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import {
|
||||
Comment,
|
||||
@@ -41,11 +37,12 @@ import {
|
||||
Embed,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
Subpages,
|
||||
TableDndExtension,
|
||||
Subpages,
|
||||
Heading,
|
||||
Highlight,
|
||||
UniqueID,
|
||||
SharedStorage,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -97,7 +94,9 @@ lowlight.register("scala", scala);
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
history: false,
|
||||
undoRedo: false,
|
||||
link: false,
|
||||
trailingNode: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
color: "#70CFF8",
|
||||
@@ -109,6 +108,7 @@ export const mainExtensions = [
|
||||
},
|
||||
},
|
||||
}),
|
||||
SharedStorage,
|
||||
Heading,
|
||||
UniqueID.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
@@ -134,8 +134,6 @@ export const mainExtensions = [
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
ListKeymap,
|
||||
Underline,
|
||||
LinkExtension.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
@@ -170,6 +168,9 @@ 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);
|
||||
},
|
||||
}),
|
||||
@@ -208,6 +209,7 @@ export const mainExtensions = [
|
||||
}),
|
||||
CustomCodeBlock.configure({
|
||||
view: CodeBlockView,
|
||||
//@ts-ignore
|
||||
lowlight,
|
||||
HTMLAttributes: {
|
||||
spellcheck: false,
|
||||
@@ -246,7 +248,7 @@ export const mainExtensions = [
|
||||
Escape: () => {
|
||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -258,8 +260,9 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
provider,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
CollaborationCaret.configure({
|
||||
provider,
|
||||
user: {
|
||||
name: user.name,
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
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,
|
||||
onAuthenticationFailedParameters,
|
||||
onStatusParameters,
|
||||
WebSocketStatus,
|
||||
HocuspocusProviderWebsocket,
|
||||
onSyncedParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
EditorProvider,
|
||||
useEditor,
|
||||
@@ -69,161 +78,140 @@ export default function PageEditor({
|
||||
editable,
|
||||
content,
|
||||
}: PageEditorProps) {
|
||||
|
||||
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorCreated = useRef(false);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
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 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 [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setIsRemoteSynced] = 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(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
|
||||
const canScroll = useCallback(
|
||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||
[isComponentMounted],
|
||||
);
|
||||
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);
|
||||
local.on("synced", () => setLocalSynced(true));
|
||||
const remote = new HocuspocusProvider({
|
||||
name: documentName,
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const remote = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
name: documentName,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
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);
|
||||
}
|
||||
},
|
||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||
onStatus: onStatusHandler,
|
||||
onSynced: onSyncedHandler,
|
||||
});
|
||||
remote.on("synced", () => setRemoteSynced(true));
|
||||
remote.on("disconnect", () => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
});
|
||||
providersRef.current = { local, remote };
|
||||
|
||||
local.on("synced", onLocalSyncedHandler);
|
||||
providersRef.current = { socket, 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 remoteProvider = providersRef.current.remote;
|
||||
const socket = providersRef.current.socket;
|
||||
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
remoteProvider.status === WebSocketStatus.Connected
|
||||
yjsConnectionStatus === WebSocketStatus.Connected
|
||||
) {
|
||||
remoteProvider.disconnect();
|
||||
setIsCollabReady(false);
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
remoteProvider.status === WebSocketStatus.Disconnected
|
||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
||||
) {
|
||||
resetIdle();
|
||||
remoteProvider.connect();
|
||||
setTimeout(() => setIsCollabReady(true), 500);
|
||||
socket.connect();
|
||||
}
|
||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||
|
||||
// Attach here, to make sure the connection gets properly established
|
||||
providersRef.current?.remote.attach();
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
||||
return mainExtensions;
|
||||
}
|
||||
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
];
|
||||
}, [remoteProvider, currentUser?.user]);
|
||||
}, [providersReady, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@@ -266,18 +254,30 @@ export default function PageEditor({
|
||||
}
|
||||
},
|
||||
},
|
||||
handlePaste: (view, event, slice) =>
|
||||
handlePaste(view, event, pageId, currentUser?.user.id),
|
||||
handleDrop: (view, event, _slice, moved) =>
|
||||
handleFileDrop(view, event, moved, pageId),
|
||||
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);
|
||||
},
|
||||
},
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setEditor(editor);
|
||||
// @ts-ignore
|
||||
editor.storage.pageId = pageId;
|
||||
handleScrollTo(editor);
|
||||
editorCreated.current = true;
|
||||
editorRef.current = editor;
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
@@ -287,7 +287,7 @@ export default function PageEditor({
|
||||
debouncedUpdateContent(editorJson);
|
||||
},
|
||||
},
|
||||
[pageId, editable, remoteProvider],
|
||||
[pageId, editable, extensions],
|
||||
);
|
||||
|
||||
const editorIsEditable = useEditorState({
|
||||
@@ -343,30 +343,17 @@ 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 collabReadyTimeout = setTimeout(() => {
|
||||
if (
|
||||
!isCollabReady &&
|
||||
isSynced &&
|
||||
remoteProvider?.status === WebSocketStatus.Connected
|
||||
) {
|
||||
setIsCollabReady(true);
|
||||
const timeout = setTimeout(() => {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
}
|
||||
}, 500);
|
||||
return () => clearTimeout(collabReadyTimeout);
|
||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||
}, 7500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
useEffect(() => {
|
||||
// Only honor user default page edit mode preference and permissions
|
||||
if (editor) {
|
||||
@@ -388,12 +375,13 @@ export default function PageEditor({
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
remoteProvider?.status === WebSocketStatus.Connected
|
||||
yjsConnectionStatus === WebSocketStatus.Connected &&
|
||||
isSynced
|
||||
) {
|
||||
hasConnectedOnceRef.current = true;
|
||||
setShowStatic(false);
|
||||
}
|
||||
}, [remoteProvider?.status]);
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
|
||||
if (showStatic) {
|
||||
return (
|
||||
|
||||
@@ -81,6 +81,7 @@ 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-cursor__caret {
|
||||
.collaboration-carets__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-cursor__label {
|
||||
.collaboration-carets__label {
|
||||
border-radius: 3px 3px 3px 0;
|
||||
color: #0d0d0d;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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", {
|
||||
@@ -8,7 +10,10 @@ export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getFileTasks(): Promise<IFileTask[]> {
|
||||
const req = await api.post<IFileTask[]>("/file-tasks");
|
||||
export async function getFileTasks(
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<IFileTask>> {
|
||||
const req = await api.post("/file-tasks", { ...params });
|
||||
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(50),
|
||||
name: z.string().trim().min(2).max(100),
|
||||
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(50),
|
||||
name: z.string().min(2).max(100),
|
||||
description: z.string().max(500),
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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();
|
||||
@@ -50,10 +51,10 @@ export default function GroupList() {
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IconGroupCircle />
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||
{group.name}
|
||||
</Text>
|
||||
</AutoTooltipText>
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{group.description}
|
||||
</Text>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import '@/features/editor/styles/index.css';
|
||||
import React, { useEffect } from 'react';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { mainExtensions } from '@/features/editor/extensions/extensions';
|
||||
import { Title } from '@mantine/core';
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, { useEffect } from "react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||
import { Title } from "@mantine/core";
|
||||
import classes from "./history.module.css";
|
||||
|
||||
export interface HistoryEditorProps {
|
||||
title: string;
|
||||
@@ -26,7 +27,9 @@ export function HistoryEditor({ title, content }: HistoryEditorProps) {
|
||||
<div>
|
||||
<Title order={1}>{title}</Title>
|
||||
|
||||
{editor && <EditorContent editor={editor} />}
|
||||
{editor && (
|
||||
<EditorContent editor={editor} className={classes.historyEditor} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) {
|
||||
mainEditorTitle
|
||||
.chain()
|
||||
.clearContent()
|
||||
.setContent(activeHistoryData.title, true)
|
||||
.setContent(activeHistoryData.title, { emitUpdate: true })
|
||||
.run();
|
||||
mainEditor
|
||||
.chain()
|
||||
|
||||
@@ -1,37 +1,49 @@
|
||||
.history {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
|
||||
}
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-8)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.historyEditor {
|
||||
:global(.ProseMirror) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-8)
|
||||
);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
max-height: rem(700px);
|
||||
width: rem(250px);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: rem(1px) solid
|
||||
max-height: rem(700px);
|
||||
width: rem(250px);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: rem(1px) solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.sidebarFlex {
|
||||
display: flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebarMain {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebarRightSection {
|
||||
flex: 1;
|
||||
padding: rem(16px) rem(40px);
|
||||
flex: 1;
|
||||
padding: rem(16px) rem(40px);
|
||||
}
|
||||
|
||||
@@ -7,22 +7,17 @@ import {
|
||||
IconHistory,
|
||||
IconLink,
|
||||
IconList,
|
||||
IconMarkdown,
|
||||
IconMessage,
|
||||
IconPrinter,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import {
|
||||
getHotkeyHandler,
|
||||
useClipboard,
|
||||
useDisclosure,
|
||||
useHotkeys,
|
||||
} from "@mantine/hooks";
|
||||
import { 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";
|
||||
@@ -34,12 +29,12 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
|
||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.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 { formattedDate } 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";
|
||||
@@ -51,7 +46,6 @@ interface PageHeaderMenuProps {
|
||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const toggleAside = useToggleAside();
|
||||
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||
|
||||
useHotkeys(
|
||||
[
|
||||
@@ -68,6 +62,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
},
|
||||
{ preventDefault: false },
|
||||
],
|
||||
],
|
||||
[],
|
||||
@@ -75,17 +70,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
<ConnectionWarning />
|
||||
|
||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||
|
||||
@@ -146,6 +131,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
notifications.show({ message: t("Link copied") });
|
||||
};
|
||||
|
||||
const handleCopyAsMarkdown = () => {
|
||||
if (!pageEditor) return;
|
||||
const html = pageEditor.getHTML();
|
||||
const markdown = htmlToMarkdown(html);
|
||||
const title = page?.title ? `# ${page.title}\n\n` : "";
|
||||
clipboard.copy(`${title}${markdown}`);
|
||||
notifications.show({ message: t("Copied") });
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
@@ -183,6 +177,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
>
|
||||
{t("Copy link")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconMarkdown size={16} />}
|
||||
onClick={handleCopyAsMarkdown}
|
||||
>
|
||||
{t("Copy as Markdown")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||
@@ -290,3 +291,51 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,6 +172,10 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
queryKey: ["root-sidebar-pages", fileTask.spaceId],
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", fileTask.spaceId],
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "refetchRootTreeNodeEvent",
|
||||
|
||||
@@ -163,9 +163,6 @@ export function useDeletePageMutation() {
|
||||
export function useMovePageMutation() {
|
||||
return useMutation<void, Error, IMovePage>({
|
||||
mutationFn: (data) => movePage(data),
|
||||
onSuccess: () => {
|
||||
invalidateOnMovePage();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -458,17 +455,127 @@ export function invalidateOnUpdatePage(
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateOnMovePage() {
|
||||
//for move invalidate all sidebars for now (how to do???)
|
||||
//invalidate all root sidebar pages
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["root-sidebar-pages"],
|
||||
});
|
||||
//invalidate all sub sidebar pages
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["sidebar-pages"],
|
||||
});
|
||||
// ---
|
||||
export function updateCacheOnMovePage(
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
oldParentId: string | null,
|
||||
newParentId: string | null,
|
||||
pageData: Partial<IPage>,
|
||||
) {
|
||||
// Remove page from old parent's cache
|
||||
const oldQueryKey =
|
||||
oldParentId === null
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
oldQueryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((item) => item.id !== pageId),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Update old parent's hasChildren flag if it has no more children
|
||||
if (oldParentId !== null) {
|
||||
const oldParentCache = queryClient.getQueryData<
|
||||
InfiniteData<IPagination<IPage>>
|
||||
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
|
||||
|
||||
const remainingChildren =
|
||||
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
||||
|
||||
if (remainingChildren === 0) {
|
||||
// Update hasChildren in all caches where old parent appears
|
||||
const allSideBarMatches = queryClient.getQueriesData({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === "root-sidebar-pages" ||
|
||||
query.queryKey[0] === "sidebar-pages",
|
||||
});
|
||||
|
||||
allSideBarMatches.forEach(([key]) => {
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
key,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((item) =>
|
||||
item.id === oldParentId
|
||||
? { ...item, hasChildren: false }
|
||||
: item,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add page to new parent's cache
|
||||
const newQueryKey =
|
||||
newParentId === null
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||
newQueryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
|
||||
// Check if page already exists in new location
|
||||
const exists = old.pages.some((page) =>
|
||||
page.items.some((item) => item.id === pageId),
|
||||
);
|
||||
if (exists) return old;
|
||||
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
if (index === old.pages.length - 1) {
|
||||
return {
|
||||
...page,
|
||||
items: [...page.items, pageData],
|
||||
};
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Update new parent's hasChildren flag
|
||||
if (newParentId !== null) {
|
||||
const allSideBarMatches = queryClient.getQueriesData({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === "root-sidebar-pages" ||
|
||||
query.queryKey[0] === "sidebar-pages",
|
||||
});
|
||||
|
||||
allSideBarMatches.forEach(([key]) => {
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((item) =>
|
||||
item.id === newParentId ? { ...item, hasChildren: true } : item,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateOnDeletePage(pageId: string) {
|
||||
|
||||
@@ -118,7 +118,14 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
|
||||
.split("filename=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
saveAs(req.data, decodeURIComponent(fileName));
|
||||
let decodedFileName = fileName;
|
||||
try {
|
||||
decodedFileName = decodeURIComponent(fileName);
|
||||
} catch (err) {
|
||||
// fallback to raw filename
|
||||
}
|
||||
|
||||
saveAs(req.data, decodedFileName);
|
||||
}
|
||||
|
||||
export async function importPage(file: File, spaceId: string) {
|
||||
|
||||
@@ -269,12 +269,15 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
|
||||
const prefetchPage = () => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["pages", node.data.slugId],
|
||||
queryFn: () => getPageById({ pageId: node.data.slugId }),
|
||||
timerRef.current = setTimeout(async () => {
|
||||
const page = await queryClient.fetchQuery({
|
||||
queryKey: ["pages", node.data.id],
|
||||
queryFn: () => getPageById({ pageId: node.data.id }),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
if (page?.slugId) {
|
||||
queryClient.setQueryData(["pages", page.slugId], page);
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useRemovePageMutation,
|
||||
useMovePageMutation,
|
||||
useUpdatePageMutation,
|
||||
updateCacheOnMovePage,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@@ -175,9 +176,25 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
parentPageId: args.parentId,
|
||||
};
|
||||
|
||||
const draggedNode = args.dragNodes[0];
|
||||
const nodeData = draggedNode.data as SpaceTreeNode;
|
||||
const oldParentId = nodeData.parentPageId ?? null;
|
||||
const pageData = {
|
||||
id: nodeData.id,
|
||||
slugId: nodeData.slugId,
|
||||
title: nodeData.name,
|
||||
icon: nodeData.icon,
|
||||
position: newPosition,
|
||||
spaceId: nodeData.spaceId,
|
||||
parentPageId: args.parentId,
|
||||
hasChildren: nodeData.hasChildren,
|
||||
};
|
||||
|
||||
try {
|
||||
await movePageMutation.mutateAsync(payload);
|
||||
|
||||
updateCacheOnMovePage(spaceId, draggedNodeId, oldParentId, args.parentId, pageData);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "moveTreeNode",
|
||||
@@ -185,8 +202,10 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
payload: {
|
||||
id: draggedNodeId,
|
||||
parentId: args.parentId,
|
||||
oldParentId,
|
||||
index: args.index,
|
||||
position: newPosition,
|
||||
pageData,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
@@ -21,12 +20,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;
|
||||
@@ -35,7 +34,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { pageSlug } = useParams();
|
||||
const pageId = extractPageSlugId(pageSlug);
|
||||
const pageSlugId = extractPageSlugId(pageSlug);
|
||||
const { data: page } = usePageQuery({ pageId: pageSlugId });
|
||||
const pageId = page?.id;
|
||||
const { data: share } = useShareForPageQuery(pageId);
|
||||
const { spaceSlug } = useParams();
|
||||
const { isTrial } = useTrial();
|
||||
|
||||
@@ -27,9 +27,7 @@ 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,
|
||||
@@ -72,7 +70,7 @@ export function useShareForPageQuery(
|
||||
queryKey: ["share-for-page", pageId],
|
||||
queryFn: () => getShareForPage(pageId),
|
||||
enabled: !!pageId,
|
||||
staleTime: 0,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React, { useEffect } from "react";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import * as z from "zod";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||
@@ -9,12 +10,12 @@ import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().min(2).max(50),
|
||||
name: z.string().trim().min(2).max(100),
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(50)
|
||||
.max(100)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9]+$/,
|
||||
"Space slug must be alphanumeric. No special characters",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ISpace } from "../types/space.types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DeleteSpaceModalProps {
|
||||
space: ISpace;
|
||||
@@ -14,6 +15,7 @@ interface DeleteSpaceModalProps {
|
||||
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const deleteSpaceMutation = useDeleteSpaceMutation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -35,12 +37,15 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// pass slug too so we can clear the local cache
|
||||
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete space", error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,7 +84,7 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
||||
<Button onClick={close} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleDelete} color="red">
|
||||
<Button onClick={handleDelete} color="red" loading={isDeleting}>
|
||||
{t("Confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -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(50),
|
||||
description: z.string().max(250),
|
||||
name: z.string().min(2).max(100),
|
||||
description: z.string().max(500),
|
||||
slug: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(50)
|
||||
.max(100)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9]+$/,
|
||||
"Space slug must be alphanumeric. No special characters",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { SpaceSelect } from "./space-select";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { Button, Popover, Text } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
@@ -21,7 +21,7 @@ export function SwitchSpace({
|
||||
spaceIcon,
|
||||
}: SwitchSpaceProps) {
|
||||
const navigate = useNavigate();
|
||||
const [opened, { close, open, toggle }] = useDisclosure(false);
|
||||
const [opened, { close, toggle }] = useDisclosure(false);
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (value) {
|
||||
@@ -44,9 +44,9 @@ export function SwitchSpace({
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronDown size={18} />}
|
||||
rightSection={opened ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
|
||||
color="gray"
|
||||
onClick={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<CustomAvatar
|
||||
name={spaceName}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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();
|
||||
@@ -48,10 +49,10 @@ export default function SpaceList() {
|
||||
variant="filled"
|
||||
name={space.name}
|
||||
/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||
{space.name}
|
||||
</Text>
|
||||
</AutoTooltipText>
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{space.description}
|
||||
</Text>
|
||||
|
||||
@@ -27,6 +27,7 @@ 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";
|
||||
|
||||
@@ -138,10 +139,10 @@ export default function SpaceMembersList({
|
||||
|
||||
{member.type === "group" && <IconGroupCircle />}
|
||||
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 260 }}>
|
||||
<AutoTooltipText fz="sm" fw={500}>
|
||||
{member?.name}
|
||||
</Text>
|
||||
</AutoTooltipText>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{member.type == "user" && member?.email}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ 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[];
|
||||
@@ -96,10 +97,10 @@ export default function AllSpacesList({
|
||||
variant="filled"
|
||||
size="md"
|
||||
/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 350 }}>
|
||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||
{space.name}
|
||||
</Text>
|
||||
</AutoTooltipText>
|
||||
{space.description && (
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{space.description}
|
||||
|
||||
@@ -69,5 +69,12 @@ export async function exportSpace(data: IExportSpaceParams): Promise<void> {
|
||||
.split("filename=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
saveAs(req.data, decodeURIComponent(fileName));
|
||||
let decodedFileName = fileName;
|
||||
try {
|
||||
decodedFileName = decodeURIComponent(fileName);
|
||||
} catch (err) {
|
||||
// fallback to raw filename
|
||||
}
|
||||
|
||||
saveAs(req.data, decodedFileName);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,10 @@ export type MoveTreeNodeEvent = {
|
||||
payload: {
|
||||
id: string;
|
||||
parentId: string;
|
||||
oldParentId: string | null;
|
||||
index: number;
|
||||
position: string;
|
||||
pageData: Partial<IPage>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { IPagination } from "@/lib/types";
|
||||
import {
|
||||
invalidateOnCreatePage,
|
||||
invalidateOnDeletePage,
|
||||
invalidateOnMovePage,
|
||||
updateCacheOnMovePage,
|
||||
invalidateOnUpdatePage,
|
||||
} from "../page/queries/page-query";
|
||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||
@@ -41,7 +41,13 @@ export const useQuerySubscription = () => {
|
||||
invalidateOnCreatePage(data.payload.data);
|
||||
break;
|
||||
case "moveTreeNode":
|
||||
invalidateOnMovePage();
|
||||
updateCacheOnMovePage(
|
||||
data.spaceId,
|
||||
data.payload.id,
|
||||
data.payload.oldParentId,
|
||||
data.payload.parentId,
|
||||
data.payload.pageData,
|
||||
);
|
||||
break;
|
||||
case "deleteTreeNode":
|
||||
invalidateOnDeletePage(data.payload.node.id);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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];
|
||||
}
|
||||
+33
-28
@@ -30,77 +30,83 @@
|
||||
"test:e2e": "jest --config test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/azure": "^2.0.47",
|
||||
"@ai-sdk/google": "^2.0.18",
|
||||
"@ai-sdk/openai": "^2.0.46",
|
||||
"@ai-sdk/google": "^3.0.9",
|
||||
"@ai-sdk/openai": "^3.0.11",
|
||||
"@ai-sdk/openai-compatible": "^2.0.12",
|
||||
"@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.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@langchain/core": "1.1.13",
|
||||
"@langchain/textsplitters": "1.0.1",
|
||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/common": "^11.1.9",
|
||||
"nestjs-cls": "^4.5.0",
|
||||
"@nestjs/common": "^11.1.11",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/core": "^11.1.11",
|
||||
"@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.9",
|
||||
"@nestjs/platform-socket.io": "^11.1.9",
|
||||
"@nestjs/schedule": "^6.0.1",
|
||||
"@nestjs/platform-fastify": "^11.1.11",
|
||||
"@nestjs/platform-socket.io": "^11.1.11",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.1.9",
|
||||
"@nestjs/websockets": "^11.1.11",
|
||||
"@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": "^5.0.65",
|
||||
"ai-sdk-ollama": "^0.12.0",
|
||||
"ai": "^6.0.37",
|
||||
"ai-sdk-ollama": "^3.1.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.65.0",
|
||||
"cache-manager": "^6.4.3",
|
||||
"cheerio": "^1.1.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"cookie": "^1.0.2",
|
||||
"fs-extra": "^11.3.0",
|
||||
"happy-dom": "20.0.10",
|
||||
"cookie": "^1.1.1",
|
||||
"fs-extra": "^11.3.3",
|
||||
"happy-dom": "20.1.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"kysely": "^0.28.2",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"kysely-postgres-js": "^3.0.0",
|
||||
"ldapts": "^7.4.0",
|
||||
"lib0": "^0.2.117",
|
||||
"mammoth": "^1.11.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"msgpackr": "^1.11.8",
|
||||
"nanoid": "3.3.11",
|
||||
"nestjs-kysely": "^1.2.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"nestjs-pino": "^4.5.0",
|
||||
"nodemailer": "^7.0.12",
|
||||
"openid-client": "^5.7.1",
|
||||
"otpauth": "^9.4.0",
|
||||
"otpauth": "^9.4.1",
|
||||
"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.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"stripe": "^17.5.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"tseep": "^1.3.1",
|
||||
"typesense": "^2.1.0",
|
||||
"ws": "^8.18.3",
|
||||
"ws": "^8.19.0",
|
||||
"yauzl": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -117,7 +123,6 @@
|
||||
"@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",
|
||||
@@ -125,7 +130,7 @@
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"globals": "^15.15.0",
|
||||
"jest": "^29.7.0",
|
||||
"kysely-codegen": "^0.17.0",
|
||||
"kysely-codegen": "^0.19.0",
|
||||
"prettier": "^3.5.1",
|
||||
"react-email": "3.0.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AuditActorInterceptor } from './common/interceptors/audit-actor.interceptor';
|
||||
import { CoreModule } from './core/core.module';
|
||||
import { EnvironmentModule } from './integrations/environment/environment.module';
|
||||
import { CollaborationModule } from './collaboration/collaboration.module';
|
||||
@@ -20,7 +18,7 @@ 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 { ClsModule } from 'nestjs-cls';
|
||||
import { LoggerModule } from './common/logger/logger.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
@@ -38,10 +36,7 @@ try {
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClsModule.forRoot({
|
||||
global: true,
|
||||
middleware: { mount: true },
|
||||
}),
|
||||
LoggerModule,
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
@@ -67,12 +62,6 @@ try {
|
||||
...enterpriseModules,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: AuditActorInterceptor,
|
||||
},
|
||||
],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -30,14 +30,22 @@ export class CollabWsAdapter {
|
||||
return this.wss;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
public close() {
|
||||
try {
|
||||
this.wss.clients.forEach((client) => {
|
||||
client.terminate();
|
||||
});
|
||||
this.wss.close();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
try {
|
||||
this.wss.close();
|
||||
this.wss.clients.forEach((client) => {
|
||||
client.terminate();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server';
|
||||
import { Hocuspocus } from '@hocuspocus/server';
|
||||
import { IncomingMessage } from 'http';
|
||||
import WebSocket from 'ws';
|
||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Redis } from '@hocuspocus/extension-redis';
|
||||
import { EnvironmentService } from '../integrations/environment/environment.service';
|
||||
import {
|
||||
createRetryStrategy,
|
||||
@@ -12,21 +11,41 @@ import {
|
||||
RedisConfig,
|
||||
} from '../common/helpers';
|
||||
import { LoggerExtension } from './extensions/logger.extension';
|
||||
import {
|
||||
RedisSyncExtension,
|
||||
SerializedHTTPRequest,
|
||||
} from './extensions/redis-sync';
|
||||
import { WsSocketWrapper } from './extensions/redis-sync/ws-socket-wrapper';
|
||||
import RedisClient from 'ioredis';
|
||||
import { pack, unpack } from 'msgpackr';
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as os from 'node:os';
|
||||
import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
||||
import {
|
||||
CollaborationHandler,
|
||||
CollabEventHandlers,
|
||||
} from './collaboration.handler';
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationGateway {
|
||||
private hocuspocus: Hocuspocus;
|
||||
private readonly hocuspocus: Hocuspocus;
|
||||
private redisConfig: RedisConfig;
|
||||
// @ts-ignore
|
||||
private readonly redisSync: RedisSyncExtension<CollabEventHandlers> | null =
|
||||
null;
|
||||
private readonly withRedis: boolean;
|
||||
|
||||
constructor(
|
||||
private authenticationExtension: AuthenticationExtension,
|
||||
private persistenceExtension: PersistenceExtension,
|
||||
private loggerExtension: LoggerExtension,
|
||||
private environmentService: EnvironmentService,
|
||||
private collabEventsService: CollaborationHandler,
|
||||
) {
|
||||
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||
this.withRedis = !this.environmentService.isCollabDisableRedis();
|
||||
|
||||
this.hocuspocus = HocuspocusServer.configure({
|
||||
this.hocuspocus = new Hocuspocus({
|
||||
debounce: 10000,
|
||||
maxDebounce: 45000,
|
||||
unloadImmediately: false,
|
||||
@@ -34,26 +53,80 @@ export class CollaborationGateway {
|
||||
this.authenticationExtension,
|
||||
this.persistenceExtension,
|
||||
this.loggerExtension,
|
||||
...(this.environmentService.isCollabDisableRedis()
|
||||
? []
|
||||
: [
|
||||
new Redis({
|
||||
host: this.redisConfig.host,
|
||||
port: this.redisConfig.port,
|
||||
options: {
|
||||
password: this.redisConfig.password,
|
||||
db: this.redisConfig.db,
|
||||
family: this.redisConfig.family,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
if (this.withRedis) {
|
||||
// @ts-ignore
|
||||
this.redisSync = new RedisSyncExtension({
|
||||
redis: new RedisClient({
|
||||
host: this.redisConfig.host,
|
||||
port: this.redisConfig.port,
|
||||
password: this.redisConfig.password,
|
||||
db: this.redisConfig.db,
|
||||
family: this.redisConfig.family,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
}),
|
||||
serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
|
||||
prefix: 'collab',
|
||||
pack,
|
||||
unpack,
|
||||
// @ts-ignore
|
||||
customEvents: this.collabEventsService.getHandlers(this.hocuspocus),
|
||||
});
|
||||
this.hocuspocus.configuration.extensions.push(this.redisSync);
|
||||
// @ts-ignore
|
||||
this.redisSync.onConfigure({ instance: this.hocuspocus });
|
||||
}
|
||||
}
|
||||
|
||||
private serializeRequest(request: IncomingMessage): SerializedHTTPRequest {
|
||||
return {
|
||||
method: request.method ?? 'GET',
|
||||
url: request.url ?? '/',
|
||||
headers: {
|
||||
'sec-websocket-key': request.headers['sec-websocket-key'] ?? '',
|
||||
'sec-websocket-protocol':
|
||||
request.headers['sec-websocket-protocol'] ?? '',
|
||||
},
|
||||
socket: { remoteAddress: request.socket?.remoteAddress ?? '' },
|
||||
};
|
||||
}
|
||||
|
||||
handleConnection(client: WebSocket, request: IncomingMessage): any {
|
||||
this.hocuspocus.handleConnection(client, request);
|
||||
if (this.redisSync) {
|
||||
const serializedHTTPRequest = this.serializeRequest(request);
|
||||
const socketId = serializedHTTPRequest.headers['sec-websocket-key'];
|
||||
|
||||
// Create wrapper socket that only receives events via emit()
|
||||
// This prevents double-handling since Hocuspocus won't listen to raw WebSocket events
|
||||
const wrappedSocket = new WsSocketWrapper(client);
|
||||
|
||||
// Route through RedisSync extension (this calls handleConnection internally)
|
||||
this.redisSync.onSocketOpen(wrappedSocket as any, serializedHTTPRequest);
|
||||
|
||||
// Forward raw WebSocket messages to the extension
|
||||
client.on('message', (data: ArrayBuffer) => {
|
||||
this.redisSync!.onSocketMessage(
|
||||
wrappedSocket as any,
|
||||
serializedHTTPRequest,
|
||||
data,
|
||||
);
|
||||
});
|
||||
|
||||
// Forward close events
|
||||
client.on('close', (code: number, reason: Buffer) => {
|
||||
this.redisSync!.onSocketClose(socketId, code, reason);
|
||||
});
|
||||
|
||||
// Forward pong events for keepalive
|
||||
client.on('pong', (data: Buffer) => {
|
||||
wrappedSocket.emit('pong', data);
|
||||
});
|
||||
} else {
|
||||
// Fallback to direct Hocuspocus connection
|
||||
this.hocuspocus.handleConnection(client, request);
|
||||
}
|
||||
}
|
||||
|
||||
getConnectionCount() {
|
||||
@@ -64,7 +137,52 @@ export class CollaborationGateway {
|
||||
return this.hocuspocus.getDocumentsCount();
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
await this.hocuspocus.destroy();
|
||||
handleYjsEvent<TName extends keyof CollabEventHandlers>(
|
||||
eventName: TName,
|
||||
documentName: string,
|
||||
payload: Parameters<CollabEventHandlers[TName]>[1],
|
||||
) {
|
||||
return this.redisSync?.handleEvent(eventName, documentName, payload);
|
||||
}
|
||||
|
||||
openDirectConnection(documentName: string, context?: any) {
|
||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
||||
}
|
||||
|
||||
/*
|
||||
*Can be used before calling openDirectConnection directly
|
||||
*/
|
||||
async lockDocument(documentName: string) {
|
||||
return this.redisSync.lockDocument(documentName);
|
||||
}
|
||||
|
||||
/*
|
||||
*Releases a document lock and stops the interval that maintains it.
|
||||
*/
|
||||
async releaseLock(documentName: string) {
|
||||
return this.redisSync.releaseLock(documentName);
|
||||
}
|
||||
|
||||
async destroy(collabWsAdapter: CollabWsAdapter): Promise<void> {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
await new Promise(async (resolve) => {
|
||||
try {
|
||||
// Wait for all documents to unload
|
||||
this.hocuspocus.configuration.extensions.push({
|
||||
async afterUnloadDocument({ instance }) {
|
||||
if (instance.getDocumentsCount() === 0) resolve('');
|
||||
},
|
||||
});
|
||||
|
||||
collabWsAdapter?.close();
|
||||
|
||||
if (this.hocuspocus.getDocumentsCount() === 0) resolve('');
|
||||
this.hocuspocus.closeConnections();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
await this.hocuspocus.hooks('onDestroy', { instance: this.hocuspocus });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Hocuspocus, Document } from '@hocuspocus/server';
|
||||
|
||||
export type CollabEventHandlers = ReturnType<
|
||||
CollaborationHandler['getHandlers']
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationHandler {
|
||||
private readonly logger = new Logger(CollaborationHandler.name);
|
||||
|
||||
constructor() {}
|
||||
|
||||
getHandlers(hocuspocus: Hocuspocus) {
|
||||
return {
|
||||
alterState: async (documentName: string, payload: { pageId: string }) => {
|
||||
// dummy
|
||||
// this.logger.log('Processing', documentName, payload);
|
||||
// await this.withYdocConnection(hocuspocus, documentName, {}, (doc) => {
|
||||
// const fragment = doc.getXmlFragment('default');
|
||||
//});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async withYdocConnection(
|
||||
hocuspocus: Hocuspocus,
|
||||
documentName: string,
|
||||
context: any = {},
|
||||
fn: (doc: Document) => void,
|
||||
): Promise<void> {
|
||||
const connection = await hocuspocus.openDirectConnection(
|
||||
documentName,
|
||||
context,
|
||||
);
|
||||
try {
|
||||
await connection.transact(fn);
|
||||
} finally {
|
||||
await connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { WebSocket } from 'ws';
|
||||
import { TokenModule } from '../core/auth/token.module';
|
||||
import { HistoryListener } from './listeners/history.listener';
|
||||
import { LoggerExtension } from './extensions/logger.extension';
|
||||
import { CollaborationHandler } from './collaboration.handler';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
@@ -17,6 +18,7 @@ import { LoggerExtension } from './extensions/logger.extension';
|
||||
PersistenceExtension,
|
||||
LoggerExtension,
|
||||
HistoryListener,
|
||||
CollaborationHandler,
|
||||
],
|
||||
exports: [CollaborationGateway],
|
||||
imports: [TokenModule],
|
||||
@@ -46,16 +48,12 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
|
||||
wss.on('error', (error) =>
|
||||
this.logger.log('WebSocket server error:', error),
|
||||
this.logger.error('WebSocket server error:', error),
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.collaborationGateway) {
|
||||
await this.collaborationGateway.destroy();
|
||||
}
|
||||
if (this.collabWsAdapter) {
|
||||
this.collabWsAdapter.destroy();
|
||||
}
|
||||
await this.collaborationGateway?.destroy(this.collabWsAdapter);
|
||||
this.collabWsAdapter?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
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,
|
||||
@@ -42,11 +40,15 @@ 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 { Node } from '@tiptap/pm/model';
|
||||
//import { generateJSON } from '@tiptap/html';
|
||||
import { Node, Schema } from '@tiptap/pm/model';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
trailingNode: false,
|
||||
heading: false,
|
||||
}),
|
||||
Heading,
|
||||
@@ -59,7 +61,6 @@ export const tiptapExtensions = [
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
Underline,
|
||||
LinkExtension,
|
||||
Superscript,
|
||||
SubScript,
|
||||
@@ -110,9 +111,53 @@ export function jsonToText(tiptapJson: JSONContent) {
|
||||
}
|
||||
|
||||
export function jsonToNode(tiptapJson: JSONContent) {
|
||||
return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson);
|
||||
const schema = getSchema(tiptapExtensions);
|
||||
try {
|
||||
return Node.fromJSON(schema, tiptapJson);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof RangeError &&
|
||||
error.message.includes('Unknown node type')
|
||||
) {
|
||||
Logger.warn('Stripping unknown node types from document:', error.message);
|
||||
const cleanedJson = stripUnknownNodes(tiptapJson, schema);
|
||||
return Node.fromJSON(schema, cleanedJson);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPageId(documentName: string) {
|
||||
return documentName.split('.')[1];
|
||||
}
|
||||
|
||||
function stripUnknownNodes(
|
||||
json: JSONContent,
|
||||
schema: Schema,
|
||||
): JSONContent | null {
|
||||
if (!json || typeof json !== 'object') return json;
|
||||
|
||||
// Recursively clean children first, flattening any unwrapped content
|
||||
if (json.content && Array.isArray(json.content)) {
|
||||
const newContent: JSONContent[] = [];
|
||||
for (const child of json.content) {
|
||||
const cleaned = stripUnknownNodes(child, schema);
|
||||
if (Array.isArray(cleaned)) {
|
||||
newContent.push(...cleaned);
|
||||
} else if (cleaned) {
|
||||
newContent.push(cleaned);
|
||||
}
|
||||
}
|
||||
json.content = newContent;
|
||||
}
|
||||
|
||||
// Check if this node is unknown AFTER processing children
|
||||
if (json.type && !schema.nodes[json.type]) {
|
||||
// Unwrap: return cleaned children directly instead of wrapping
|
||||
return (
|
||||
json.content && json.content.length > 0 ? json.content : null
|
||||
) as any;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension {
|
||||
}
|
||||
|
||||
if (userSpaceRole === SpaceRole.READER) {
|
||||
data.connection.readOnly = true;
|
||||
data.connectionConfig.readOnly = true;
|
||||
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
export class LoggerExtension implements Extension {
|
||||
private readonly logger = new Logger('Collab' + LoggerExtension.name);
|
||||
|
||||
async onDisconnect(data: onDisconnectPayload) {
|
||||
this.logger.debug(`User disconnected from "${data.documentName}".`);
|
||||
}
|
||||
|
||||
async afterUnloadDocument(data: onLoadDocumentPayload) {
|
||||
this.logger.debug('Unloaded ' + data.documentName + ' from memory');
|
||||
}
|
||||
|
||||
async onDisconnect(data: onDisconnectPayload) {
|
||||
this.logger.debug('User disconnected from ' + data.documentName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type RedisClient from 'ioredis';
|
||||
import { EventEmitter } from 'tseep';
|
||||
import type {
|
||||
Pack,
|
||||
RSAMessageClose,
|
||||
RSAMessagePing,
|
||||
RSAMessageSend,
|
||||
} from './redis-sync.types';
|
||||
|
||||
export class CollabProxySocket extends EventEmitter {
|
||||
private readonly replyTo: string;
|
||||
private readonly serverChannel: string;
|
||||
private readonly socketId: string;
|
||||
private pub: RedisClient;
|
||||
private readonly pack: Pack;
|
||||
readyState = 1;
|
||||
|
||||
constructor(
|
||||
pub: RedisClient,
|
||||
pack: Pack,
|
||||
replyTo: string,
|
||||
serverChannel: string,
|
||||
socketId: string,
|
||||
) {
|
||||
super();
|
||||
this.replyTo = replyTo;
|
||||
this.socketId = socketId;
|
||||
this.serverChannel = serverChannel;
|
||||
this.pub = pub;
|
||||
this.pack = pack;
|
||||
this.once('close', () => {
|
||||
this.readyState = 3;
|
||||
});
|
||||
}
|
||||
|
||||
private publish(msg: RSAMessageClose | RSAMessagePing | RSAMessageSend) {
|
||||
this.pub.publish(this.replyTo, this.pack(msg));
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
if (this.readyState !== 1) return;
|
||||
const msg: RSAMessageClose = {
|
||||
type: 'close',
|
||||
code,
|
||||
reason,
|
||||
socketId: this.socketId,
|
||||
};
|
||||
this.publish(msg);
|
||||
}
|
||||
|
||||
ping() {
|
||||
if (this.readyState !== 1) return;
|
||||
const msg: RSAMessagePing = {
|
||||
type: 'ping',
|
||||
socketId: this.socketId,
|
||||
replyTo: this.serverChannel,
|
||||
};
|
||||
this.publish(msg);
|
||||
}
|
||||
|
||||
send(message: Uint8Array) {
|
||||
if (this.readyState !== 1) return;
|
||||
const msg: RSAMessageSend = {
|
||||
type: 'send',
|
||||
socketId: this.socketId,
|
||||
message,
|
||||
};
|
||||
this.publish(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './redis-sync.extension';
|
||||
export type { SerializedHTTPRequest } from './redis-sync.extension';
|
||||
@@ -0,0 +1,378 @@
|
||||
// Source https://github.com/ueberdosis/hocuspocus/pull/1008 - MIT
|
||||
import {
|
||||
Extension,
|
||||
Hocuspocus,
|
||||
IncomingMessage,
|
||||
afterUnloadDocumentPayload,
|
||||
onConfigurePayload,
|
||||
onLoadDocumentPayload,
|
||||
} from '@hocuspocus/server';
|
||||
import RedisClient from 'ioredis';
|
||||
import { readVarString } from 'lib0/decoding.js';
|
||||
import { CollabProxySocket } from './collab-proxy-socket';
|
||||
import {
|
||||
BaseWebSocket,
|
||||
Configuration,
|
||||
CustomEvents,
|
||||
Pack,
|
||||
RSAMessage,
|
||||
RSAMessageCloseProxy,
|
||||
RSAMessageCustomEventComplete,
|
||||
RSAMessageCustomEventStart,
|
||||
RSAMessagePong,
|
||||
RSAMessageProxy,
|
||||
RSAMessageUnload,
|
||||
SerializedHTTPRequest,
|
||||
Unpack,
|
||||
} from './redis-sync.types';
|
||||
|
||||
export type { Pack, SerializedHTTPRequest } from './redis-sync.types';
|
||||
|
||||
type ServerId = string;
|
||||
type DocumentName = string;
|
||||
type SocketId = string;
|
||||
|
||||
export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
priority = 1000;
|
||||
private readonly pub: RedisClient;
|
||||
private sub: RedisClient;
|
||||
private readonly pack: Pack;
|
||||
private readonly unpack: Unpack;
|
||||
private originSockets: Record<SocketId, BaseWebSocket> = {};
|
||||
private locks: Record<DocumentName, NodeJS.Timeout> = {};
|
||||
private lockPromises: Record<DocumentName, Promise<ServerId | null>> = {};
|
||||
private proxySockets: Record<SocketId, CollabProxySocket> = {};
|
||||
private readonly prefix: string;
|
||||
private readonly lockPrefix: string;
|
||||
private readonly msgChannel: string;
|
||||
private readonly serverId: ServerId;
|
||||
private readonly customEventTTL: number;
|
||||
private readonly lockTTL: number;
|
||||
private instance!: Hocuspocus;
|
||||
private readonly customEvents: TCE;
|
||||
private replyIdCounter: number = 0;
|
||||
// @ts-ignore
|
||||
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> =
|
||||
{};
|
||||
|
||||
constructor(configuration: Configuration<TCE>) {
|
||||
const {
|
||||
redis,
|
||||
pack,
|
||||
unpack,
|
||||
serverId,
|
||||
lockTTL,
|
||||
prefix,
|
||||
customEvents,
|
||||
customEventTTL,
|
||||
} = configuration;
|
||||
this.pub = redis.duplicate();
|
||||
this.sub = redis.duplicate();
|
||||
this.pack = pack;
|
||||
this.unpack = unpack;
|
||||
this.serverId = serverId;
|
||||
this.lockTTL = lockTTL ?? 10_000;
|
||||
this.customEventTTL = customEventTTL ?? 30_000;
|
||||
this.prefix = prefix ?? 'collab';
|
||||
this.lockPrefix = `${this.prefix}Lock`;
|
||||
this.msgChannel = `${this.prefix}Msg`;
|
||||
this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents);
|
||||
this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`);
|
||||
this.sub.on('messageBuffer', this.handleRedisMessage);
|
||||
this.pub.on('error', () => {});
|
||||
this.sub.on('error', () => {});
|
||||
}
|
||||
private getKey(documentName: string) {
|
||||
return `${this.lockPrefix}:${documentName}`;
|
||||
}
|
||||
|
||||
private closeProxy(socketId: string) {
|
||||
const proxySocket = this.proxySockets[socketId];
|
||||
if (proxySocket) {
|
||||
proxySocket.emit(
|
||||
'close',
|
||||
1000,
|
||||
Buffer.from('provider_initiated', 'utf-8'),
|
||||
);
|
||||
delete this.proxySockets[socketId];
|
||||
}
|
||||
}
|
||||
|
||||
private pongProxy(socketId: string) {
|
||||
this.proxySockets[socketId]?.emit('pong');
|
||||
}
|
||||
|
||||
private handleProxyMessage(
|
||||
msg: Pick<RSAMessageProxy, 'replyTo' | 'message' | 'serializedHTTPRequest'>,
|
||||
) {
|
||||
const { replyTo, message, serializedHTTPRequest } = msg;
|
||||
const { headers } = serializedHTTPRequest;
|
||||
const socketId = headers['sec-websocket-key']!;
|
||||
let socket = this.proxySockets[socketId];
|
||||
if (!socket) {
|
||||
socket = new CollabProxySocket(
|
||||
this.pub,
|
||||
this.pack,
|
||||
replyTo,
|
||||
`${this.msgChannel}:${this.serverId}`,
|
||||
socketId,
|
||||
);
|
||||
this.proxySockets[socketId] = socket;
|
||||
this.instance.handleConnection(
|
||||
socket as any,
|
||||
serializedHTTPRequest as any,
|
||||
{},
|
||||
);
|
||||
}
|
||||
socket.emit('message', message);
|
||||
}
|
||||
|
||||
private getOrClaimLock(documentName: string) {
|
||||
const lockPromise = this.pub.set(
|
||||
this.getKey(documentName),
|
||||
this.serverId,
|
||||
'PX',
|
||||
this.lockTTL,
|
||||
'NX',
|
||||
'GET',
|
||||
);
|
||||
this.lockPromises[documentName] = lockPromise;
|
||||
// Briefly cache the serverId that claimed the doc to reduce load on redis
|
||||
// When the claimant unloads the doc, it will send an unload message to immediately clear this
|
||||
// a lockTTL / 2 guarantees stale reads < lockTTL upon server crash
|
||||
setTimeout(() => {
|
||||
delete this.lockPromises[documentName];
|
||||
}, this.lockTTL / 2);
|
||||
return lockPromise;
|
||||
}
|
||||
|
||||
private getOrClaimLockThrottled(documentName: string) {
|
||||
const existingWorkerIdPromise = this.lockPromises[documentName];
|
||||
if (existingWorkerIdPromise) return existingWorkerIdPromise;
|
||||
return this.getOrClaimLock(documentName);
|
||||
}
|
||||
|
||||
private handleRedisMessage = async (
|
||||
_channel: Buffer,
|
||||
packedMessage: Buffer,
|
||||
) => {
|
||||
const msg = this.unpack(packedMessage) as RSAMessage;
|
||||
const { type } = msg;
|
||||
if (type === 'proxy') {
|
||||
this.handleProxyMessage(msg);
|
||||
return;
|
||||
}
|
||||
if (type === 'closeProxy') {
|
||||
this.closeProxy(msg.socketId);
|
||||
return;
|
||||
}
|
||||
if (type === 'pong') {
|
||||
this.pongProxy(msg.socketId);
|
||||
return;
|
||||
}
|
||||
if (type === 'unload') {
|
||||
delete this.lockPromises[msg.documentName];
|
||||
return;
|
||||
}
|
||||
if (type === 'customEventStart') {
|
||||
const { documentName, eventName, payload, replyTo, replyId } = msg;
|
||||
const res = await this.handleEventLocally(
|
||||
eventName as Extract<keyof TCE, string>,
|
||||
documentName,
|
||||
payload,
|
||||
);
|
||||
const reply: RSAMessageCustomEventComplete = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: res,
|
||||
};
|
||||
this.pub.publish(`${replyTo}`, this.pack(reply));
|
||||
return;
|
||||
}
|
||||
if (type === 'customEventComplete') {
|
||||
const { replyId, payload } = msg;
|
||||
const resolveFn = this.pendingReplies[replyId];
|
||||
if (!resolveFn) return;
|
||||
delete this.pendingReplies[replyId];
|
||||
resolveFn(payload);
|
||||
return;
|
||||
}
|
||||
const { socketId } = msg;
|
||||
const socket = this.originSockets[socketId];
|
||||
if (!socket) {
|
||||
// origin socket already cleaned up
|
||||
return;
|
||||
}
|
||||
if (type === 'close') {
|
||||
socket.close(msg.code, msg.reason);
|
||||
} else if (type === 'ping') {
|
||||
// Reply instantly to the proxy socket, without forwarding to client
|
||||
// The origin socket handles heartbeat for itself
|
||||
const { replyTo, socketId } = msg;
|
||||
const reply: RSAMessagePong = {
|
||||
type: 'pong',
|
||||
socketId,
|
||||
};
|
||||
this.pub.publish(`${replyTo}`, this.pack(reply));
|
||||
} else if (type === 'send') {
|
||||
socket.send(msg.message);
|
||||
}
|
||||
};
|
||||
|
||||
async maintainLock(documentName: string) {
|
||||
this.locks[documentName] = setInterval(() => {
|
||||
this.pub.set(
|
||||
this.getKey(documentName),
|
||||
this.serverId,
|
||||
'PX',
|
||||
this.lockTTL,
|
||||
);
|
||||
}, this.lockTTL / 2);
|
||||
}
|
||||
|
||||
async releaseLock(documentName: string) {
|
||||
clearInterval(this.locks[documentName]);
|
||||
delete this.locks[documentName];
|
||||
return this.pub.del(this.getKey(documentName));
|
||||
}
|
||||
|
||||
private async handleEventLocally<TName extends Extract<keyof TCE, string>>(
|
||||
eventName: TName,
|
||||
documentName: string,
|
||||
payload: any,
|
||||
) {
|
||||
const handler = this.customEvents[eventName];
|
||||
if (!handler) throw new Error(`Invalid eventName: ${eventName}`);
|
||||
const result = await handler(documentName, payload);
|
||||
return result as Promise<ReturnType<TCE[TName]>>;
|
||||
}
|
||||
|
||||
async handleEvent<TName extends Extract<keyof TCE, string>>(
|
||||
eventName: TName,
|
||||
documentName: string,
|
||||
payload: any,
|
||||
) {
|
||||
const isDocLoadedOnInstance = this.instance.documents.has(documentName);
|
||||
|
||||
if (isDocLoadedOnInstance) {
|
||||
return this.handleEventLocally(eventName, documentName, payload);
|
||||
}
|
||||
|
||||
const proxyTo = await this.getOrClaimLockThrottled(documentName);
|
||||
if (proxyTo && proxyTo !== this.serverId) {
|
||||
++this.replyIdCounter; // bug in biome thinks this.replyIdCounter is not used if written on the line below
|
||||
const replyId = this.replyIdCounter;
|
||||
// another server owns the doc
|
||||
const proxyMessage: RSAMessageCustomEventStart = {
|
||||
eventName,
|
||||
documentName,
|
||||
payload,
|
||||
replyTo: `${this.msgChannel}:${this.serverId}`,
|
||||
replyId,
|
||||
type: 'customEventStart',
|
||||
};
|
||||
const msg = this.pack(proxyMessage);
|
||||
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
|
||||
// @ts-ignore
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
this.pendingReplies[replyId] = resolve;
|
||||
setTimeout(() => {
|
||||
reject('TIMEOUT');
|
||||
}, this.customEventTTL);
|
||||
return promise as Promise<ReturnType<TCE[TName]>>;
|
||||
}
|
||||
// This server owns the document, but hocuspocus hasn't loaded it yet
|
||||
return this.handleEventLocally(eventName, documentName, payload);
|
||||
}
|
||||
|
||||
async lockDocument(documentName: string) {
|
||||
const proxyTo = await this.getOrClaimLockThrottled(documentName);
|
||||
if (proxyTo && proxyTo !== this.serverId) {
|
||||
throw new Error(`Could not lock document: ${documentName}`);
|
||||
}
|
||||
this.maintainLock(documentName);
|
||||
return () => this.releaseLock(documentName);
|
||||
}
|
||||
|
||||
/* WebSocket Server Hooks */
|
||||
onSocketOpen(
|
||||
ws: BaseWebSocket,
|
||||
serializedHTTPRequest: SerializedHTTPRequest,
|
||||
context = {},
|
||||
) {
|
||||
const socketId = serializedHTTPRequest.headers['sec-websocket-key']!;
|
||||
this.originSockets[socketId] = ws;
|
||||
this.instance.handleConnection(
|
||||
ws as any,
|
||||
serializedHTTPRequest as any,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
async onSocketMessage(
|
||||
ws: BaseWebSocket,
|
||||
serializedHTTPRequest: SerializedHTTPRequest,
|
||||
detachableMsg: ArrayBuffer,
|
||||
) {
|
||||
const message = new Uint8Array(detachableMsg.slice());
|
||||
const tmpMsg = new IncomingMessage(detachableMsg);
|
||||
const documentName = readVarString(tmpMsg.decoder);
|
||||
const isDocLoadedOnInstance = this.instance.documents.has(documentName);
|
||||
|
||||
if (isDocLoadedOnInstance) {
|
||||
ws.emit('message', message);
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyTo = await this.getOrClaimLockThrottled(documentName);
|
||||
if (proxyTo && proxyTo !== this.serverId) {
|
||||
// another server owns the doc
|
||||
const proxyMessage: RSAMessageProxy = {
|
||||
serializedHTTPRequest: serializedHTTPRequest,
|
||||
replyTo: `${this.msgChannel}:${this.serverId}`,
|
||||
message,
|
||||
type: 'proxy',
|
||||
};
|
||||
const msg = this.pack(proxyMessage);
|
||||
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
|
||||
return;
|
||||
}
|
||||
// This server owns the document, but hocuspocus hasn't loaded it yet
|
||||
ws.emit('message', message);
|
||||
}
|
||||
|
||||
onSocketClose(socketId: string, code?: number, reason?: ArrayBuffer) {
|
||||
const socket = this.originSockets[socketId];
|
||||
if (!socket) return;
|
||||
// at this point the socket is considered GC'd and we cannot call close
|
||||
// The origin socket did not set up any connections for the proxy, so none of the hooks will work if we just emit
|
||||
socket?.emit('close', code, reason);
|
||||
delete this.originSockets[socketId];
|
||||
const msg: RSAMessageCloseProxy = { type: 'closeProxy', socketId };
|
||||
this.pub.publish(this.msgChannel, this.pack(msg)).catch(() => {});
|
||||
}
|
||||
|
||||
/* Hocuspocus hooks */
|
||||
async onConfigure({ instance }: onConfigurePayload) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
const { documentName } = data;
|
||||
// Refresh the lock TTL
|
||||
this.maintainLock(documentName);
|
||||
}
|
||||
|
||||
async afterUnloadDocument(data: afterUnloadDocumentPayload) {
|
||||
const { documentName } = data;
|
||||
this.releaseLock(documentName);
|
||||
// Broadcast to cluster to immediately remove the cached redis value
|
||||
const msg: RSAMessageUnload = { type: 'unload', documentName };
|
||||
this.pub.publish(this.msgChannel, this.pack(msg));
|
||||
}
|
||||
|
||||
async onDestroy() {
|
||||
this.pub.disconnect(false);
|
||||
this.sub.disconnect(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import EventEmitter from 'node:events';
|
||||
import { IncomingHttpHeaders } from 'node:http2';
|
||||
import RedisClient from 'ioredis';
|
||||
|
||||
export type SecondParam<T> = T extends (
|
||||
arg1: unknown,
|
||||
arg2: infer A,
|
||||
...args: unknown[]
|
||||
) => unknown
|
||||
? A
|
||||
: never;
|
||||
|
||||
export type SerializedHTTPRequest = {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: IncomingHttpHeaders;
|
||||
socket: { remoteAddress: string };
|
||||
};
|
||||
|
||||
export type RSAMessageProxy = {
|
||||
type: 'proxy';
|
||||
replyTo: string;
|
||||
message: Uint8Array<ArrayBufferLike>;
|
||||
serializedHTTPRequest: SerializedHTTPRequest;
|
||||
};
|
||||
|
||||
export type RSAMessageCloseProxy = {
|
||||
type: 'closeProxy';
|
||||
socketId: string;
|
||||
};
|
||||
|
||||
export type RSAMessageUnload = {
|
||||
type: 'unload';
|
||||
documentName: string;
|
||||
};
|
||||
|
||||
export type RSAMessageClose = {
|
||||
type: 'close';
|
||||
code?: number;
|
||||
reason?: string;
|
||||
socketId: string;
|
||||
};
|
||||
|
||||
export type RSAMessagePing = {
|
||||
type: 'ping';
|
||||
socketId: string;
|
||||
replyTo: string;
|
||||
};
|
||||
|
||||
export type RSAMessagePong = {
|
||||
type: 'pong';
|
||||
socketId: string;
|
||||
};
|
||||
|
||||
export type RSAMessageSend = {
|
||||
type: 'send';
|
||||
// @ts-ignore
|
||||
message: Uint8Array<ArrayBufferLike>;
|
||||
socketId: string;
|
||||
};
|
||||
|
||||
export type RSAMessageCustomEventStart<TName = string, TPayload = unknown> = {
|
||||
type: 'customEventStart';
|
||||
documentName: string;
|
||||
eventName: TName;
|
||||
payload: TPayload;
|
||||
replyTo: string;
|
||||
replyId: number;
|
||||
};
|
||||
|
||||
export type RSAMessageCustomEventComplete = {
|
||||
type: 'customEventComplete';
|
||||
replyId: number;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
export type RSAMessage =
|
||||
| RSAMessageProxy
|
||||
| RSAMessageCloseProxy
|
||||
| RSAMessageUnload
|
||||
| RSAMessageClose
|
||||
| RSAMessagePing
|
||||
| RSAMessagePong
|
||||
| RSAMessageSend
|
||||
| RSAMessageCustomEventStart
|
||||
| RSAMessageCustomEventComplete;
|
||||
|
||||
// @ts-ignore
|
||||
export type Pack = (msg: RSAMessage) => string | Buffer<ArrayBufferLike>;
|
||||
|
||||
export type Unpack = (
|
||||
// @ts-ignore
|
||||
packedMessage: Uint8Array | Buffer<ArrayBufferLike>,
|
||||
) => RSAMessage;
|
||||
|
||||
type ServerId = string;
|
||||
type DocumentName = string;
|
||||
type CustomEventName = string;
|
||||
|
||||
export type CustomEvents = Record<
|
||||
CustomEventName,
|
||||
(documentName: string, payload: unknown) => Promise<unknown>
|
||||
>;
|
||||
|
||||
export interface Configuration<TCE> {
|
||||
redis: RedisClient;
|
||||
pack: Pack;
|
||||
unpack: Unpack;
|
||||
serverId: ServerId;
|
||||
lockTTL?: number;
|
||||
customEventTTL?: number;
|
||||
prefix?: string;
|
||||
customEvents?: TCE;
|
||||
}
|
||||
|
||||
export type BaseWebSocket = EventEmitter & {
|
||||
readyState: number;
|
||||
close(code?: number, reason?: string): void;
|
||||
ping(): void;
|
||||
send(message: Uint8Array): void;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type WebSocket from 'ws';
|
||||
|
||||
/**
|
||||
* Wrapper around ws WebSocket that only receives events via emit().
|
||||
* This prevents double-handling when used with RedisSyncExtension.
|
||||
*/
|
||||
export class WsSocketWrapper extends EventEmitter {
|
||||
private ws: WebSocket;
|
||||
readyState = 1;
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
super();
|
||||
this.ws = ws;
|
||||
this.once('close', () => {
|
||||
this.readyState = 3;
|
||||
});
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
if (this.readyState !== 1) return;
|
||||
this.readyState = 3;
|
||||
try {
|
||||
this.ws.close(code, reason);
|
||||
} catch (e) {
|
||||
// Socket already closed
|
||||
}
|
||||
}
|
||||
|
||||
ping() {
|
||||
if (this.readyState !== 1) return;
|
||||
try {
|
||||
this.ws.ping();
|
||||
} catch (e) {
|
||||
// Socket already closed
|
||||
}
|
||||
}
|
||||
|
||||
send(message: Uint8Array) {
|
||||
if (this.readyState !== 1) return;
|
||||
try {
|
||||
this.ws.send(message);
|
||||
} catch (e) {
|
||||
// Socket already closed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,11 @@ 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,22 +5,27 @@ 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>(
|
||||
CollabAppModule,
|
||||
new FastifyAdapter({
|
||||
ignoreTrailingSlash: true,
|
||||
ignoreDuplicateSlashes: true,
|
||||
maxParamLength: 500,
|
||||
routerOptions: {
|
||||
maxParamLength: 1000,
|
||||
ignoreTrailingSlash: true,
|
||||
ignoreDuplicateSlashes: true,
|
||||
},
|
||||
}),
|
||||
{
|
||||
logger: new InternalLogFilter(),
|
||||
logger: false,
|
||||
bufferLogs: false,
|
||||
},
|
||||
);
|
||||
|
||||
app.useLogger(app.get(PinoLogger));
|
||||
|
||||
app.setGlobalPrefix('api', { exclude: ['/'] });
|
||||
|
||||
app.enableCors();
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
export const AuditEvent = {
|
||||
// Workspace Invitations
|
||||
WORKSPACE_CREATED: 'workspace.created',
|
||||
WORKSPACE_INVITE_CREATED: 'workspace.invite_created',
|
||||
WORKSPACE_INVITE_REVOKED: 'workspace.invite_revoked',
|
||||
|
||||
WORKSPACE_INVITE_ACCEPTED: 'workspace.invite_accepted',
|
||||
|
||||
WORKSPACE_USER_CREATED: 'workspace.user_created',
|
||||
WORKSPACE_USER_DEACTIVATED: 'workspace.user_deactivated',
|
||||
|
||||
WORKSPACE_ALLOWED_DOMAIN_UPDATED: 'workspace.allowed_domain_updated',
|
||||
WORKSPACE_ICON_CHANGED: 'workspace.icon_changed',
|
||||
WORKSPACE_NAME_CHANGED: 'workspace.name_changed',
|
||||
|
||||
WORKSPACE_AI_TOGGLED: 'workspace.ai_toggled',
|
||||
|
||||
USER_CREATED: 'user.created',
|
||||
USER_DELETED: 'user.deleted',
|
||||
USER_LOGIN: 'user.login',
|
||||
USER_LOGOUT: 'user.logout',
|
||||
USER_ROLE_CHANGED: 'user.user_role_changed',
|
||||
USER_PASSWORD_CHANGED: 'user.password_changed',
|
||||
USER_PASSWORD_RESET: 'user.reset_password',
|
||||
USER_PHOTO_CHANGED: 'user.reset_password',
|
||||
USER_NAME_CHANGED: 'user.name_changed',
|
||||
USER_EMAIL_CHANGED: 'user.email_changed',
|
||||
USER_MFA_SETUP: 'user.mfa_setup',
|
||||
USER_MFA_BACKUP_CODE_GENERATED: 'user.mfa_backup_code_generated',
|
||||
|
||||
// API Keys
|
||||
API_KEY_CREATED: 'api_key.created',
|
||||
API_KEY_UPDATED: 'api_key.updated',
|
||||
API_KEY_DELETED: 'api_key.deleted',
|
||||
|
||||
// Space
|
||||
SPACE_CREATED: 'space.created',
|
||||
SPACE_UPDATED: 'space.updated',
|
||||
SPACE_DELETED: 'space.deleted',
|
||||
|
||||
SPACE_MEMBER_ADDED: 'space.member_added',
|
||||
SPACE_MEMBER_REMOVED: 'space.member_removed',
|
||||
SPACE_MEMBER_ROLE_CHANGED: 'space.member_role_changed',
|
||||
|
||||
// OR SPACE_USER_ADDED: 'space.user_added',
|
||||
// SPACE_GROUP_ADDED: 'space.group_added',
|
||||
|
||||
// GROUP
|
||||
GROUP_CREATED: 'group.created',
|
||||
GROUP_UPDATED: 'group.updated',
|
||||
GROUP_DELETED: 'group.deleted',
|
||||
|
||||
GROUP_MEMBER_ADDED: 'group.member_added',
|
||||
GROUP_MEMBER_REMOVED: 'group.member_removed',
|
||||
|
||||
// Comments
|
||||
COMMENT_CREATED: 'comment.created',
|
||||
COMMENT_UPDATED: 'comment.updated',
|
||||
COMMENT_DELETED: 'comment.deleted',
|
||||
COMMENT_RESOLVED: 'comment.resolved',
|
||||
COMMENT_REOPENED: 'comment.reopened',
|
||||
|
||||
// PAGE
|
||||
PAGE_CREATED: 'page.created',
|
||||
PAGE_UPDATED: 'page.updated',
|
||||
PAGE_TRASHED: 'page.trash',
|
||||
PAGE_DELETED: 'page.deleted',
|
||||
PAGE_SHARED: 'page.shared',
|
||||
|
||||
ATTACHMENT_UPLOADED: 'attachment.uploaded',
|
||||
|
||||
PAGE_IMPORTED: 'page.imported',
|
||||
PAGE_RESTORED: 'page.restored',
|
||||
PAGE_EXPORTED: 'page.exported',
|
||||
SPACE_EXPORTED: 'space.imported',
|
||||
|
||||
// SSO EVENTS
|
||||
} as const;
|
||||
|
||||
export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent];
|
||||
|
||||
export type ActorType = 'user' | 'system' | 'api_key';
|
||||
|
||||
export interface AuditLogPayload {
|
||||
event: AuditEventType;
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
changes?: {
|
||||
before?: Record<string, any>;
|
||||
after?: Record<string, any>;
|
||||
};
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AuditLogData extends AuditLogPayload {
|
||||
workspaceId: string;
|
||||
actorId?: string;
|
||||
actorType: ActorType;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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);
|
||||
@@ -0,0 +1,16 @@
|
||||
export type ExportPageMetadata = {
|
||||
pageId: string;
|
||||
slugId: string;
|
||||
icon: string | null;
|
||||
position: string;
|
||||
parentPath: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ExportMetadata = {
|
||||
exportedAt: string;
|
||||
source: 'docmost';
|
||||
version: string;
|
||||
pages: Record<string, ExportPageMetadata>;
|
||||
};
|
||||
@@ -2,6 +2,7 @@ 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');
|
||||
|
||||
@@ -98,3 +99,38 @@ 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 };
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { AuditContext, AUDIT_CONTEXT_KEY } from '../middlewares/audit-context.middleware';
|
||||
|
||||
@Injectable()
|
||||
export class AuditActorInterceptor implements NestInterceptor {
|
||||
constructor(private readonly cls: ClsService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user?.user;
|
||||
|
||||
if (user?.id) {
|
||||
const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY);
|
||||
if (auditContext) {
|
||||
auditContext.actorId = user.id;
|
||||
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
|
||||
}
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Params } from 'nestjs-pino';
|
||||
import { stdTimeFunctions } from 'pino';
|
||||
|
||||
const CONTEXTS_TO_IGNORE = [
|
||||
'InstanceLoader',
|
||||
'RoutesResolver',
|
||||
'RouterExplorer',
|
||||
'LegacyRouteConverter',
|
||||
'WebSocketsController',
|
||||
];
|
||||
|
||||
export function createPinoConfig(): Params {
|
||||
const isProduction = process.env.NODE_ENV?.toLowerCase() === 'production';
|
||||
const isDebugMode = process.env.DEBUG_MODE?.toLowerCase() === 'true';
|
||||
const logHttp = process.env.LOG_HTTP?.toLowerCase() === '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 }),
|
||||
},
|
||||
hooks: {
|
||||
logMethod(inputArgs, method) {
|
||||
if (isProduction && !isDebugMode) {
|
||||
for (const arg of inputArgs) {
|
||||
if (typeof arg === 'object' && arg !== null && 'context' in arg) {
|
||||
const context = (arg as Record<string, unknown>)['context'];
|
||||
if (typeof context === 'string' && CONTEXTS_TO_IGNORE.includes(context)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return method.apply(this, inputArgs);
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
|
||||
export interface AuditContext {
|
||||
workspaceId: string | null;
|
||||
actorId: string | null;
|
||||
actorType: 'user' | 'system' | 'api_key';
|
||||
ipAddress: string | null;
|
||||
}
|
||||
|
||||
export const AUDIT_CONTEXT_KEY = 'auditContext';
|
||||
|
||||
@Injectable()
|
||||
export class AuditContextMiddleware implements NestMiddleware {
|
||||
constructor(private readonly cls: ClsService) {}
|
||||
|
||||
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
|
||||
const workspaceId = (req as any).workspaceId ?? null;
|
||||
const ipAddress = this.extractIpAddress(req);
|
||||
|
||||
const auditContext: AuditContext = {
|
||||
workspaceId,
|
||||
actorId: null,
|
||||
actorType: 'user',
|
||||
ipAddress,
|
||||
};
|
||||
|
||||
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
private extractIpAddress(req: FastifyRequest['raw']): string | null {
|
||||
const xForwardedFor = req.headers['x-forwarded-for'];
|
||||
if (xForwardedFor) {
|
||||
const ips = Array.isArray(xForwardedFor)
|
||||
? xForwardedFor[0]
|
||||
: xForwardedFor.split(',')[0];
|
||||
return ips?.trim() ?? null;
|
||||
}
|
||||
|
||||
const xRealIp = req.headers['x-real-ip'];
|
||||
if (xRealIp) {
|
||||
return Array.isArray(xRealIp) ? xRealIp[0] : xRealIp;
|
||||
}
|
||||
|
||||
return (req as any).socket?.remoteAddress ?? null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user