Compare commits

..
Author SHA1 Message Date
Philip Okugbe 642ada0e44 New translations translation.json (Portuguese, Brazilian)
[ci skip]
2026-08-25 03:16:58 +01:00
Philip Okugbe 90cf2a8c0a New translations translation.json (English)
[ci skip]
2026-08-25 03:16:56 +01:00
Philip Okugbe d4ae397085 New translations translation.json (Chinese Simplified)
[ci skip]
2026-08-25 03:16:54 +01:00
Philip Okugbe f066722804 New translations translation.json (Ukrainian)
[ci skip]
2026-08-25 03:16:53 +01:00
Philip Okugbe 20e5796622 New translations translation.json (Russian)
[ci skip]
2026-08-25 03:16:51 +01:00
Philip Okugbe abae4e1dcc New translations translation.json (Dutch)
[ci skip]
2026-08-25 03:16:49 +01:00
Philip Okugbe ad14442e5f New translations translation.json (Japanese)
[ci skip]
2026-08-25 03:16:47 +01:00
Philip Okugbe 6f35067034 New translations translation.json (Italian)
[ci skip]
2026-08-25 03:16:45 +01:00
Philip Okugbe cc8dc9856f New translations translation.json (German)
[ci skip]
2026-08-25 03:16:44 +01:00
Philip Okugbe 5ef3a5447c New translations translation.json (French)
[ci skip]
2026-08-25 03:16:42 +01:00
Philip Okugbe 934d45143c New translations translation.json (Spanish)
[ci skip]
2026-08-25 03:16:40 +01:00
Philip Okugbe 976c290fa7 New translations translation.json (Korean)
[ci skip]
2026-08-25 03:16:38 +01:00
Philip Okugbe 34a501e6a2 New translations translation.json (Portuguese, Brazilian)
[ci skip]
2026-08-25 02:14:50 +01:00
Philip Okugbe 943d3b6361 New translations translation.json (English)
[ci skip]
2026-08-25 02:14:49 +01:00
Philip Okugbe 5f7699ef16 New translations translation.json (Chinese Simplified)
[ci skip]
2026-08-25 02:14:47 +01:00
Philip Okugbe bde03487e0 New translations translation.json (Ukrainian)
[ci skip]
2026-08-25 02:14:45 +01:00
Philip Okugbe d17b6bd92f New translations translation.json (Russian)
[ci skip]
2026-08-25 02:14:43 +01:00
Philip Okugbe cd1ad5cfcc New translations translation.json (Dutch)
[ci skip]
2026-08-25 02:14:41 +01:00
Philip Okugbe d7087faf52 New translations translation.json (Japanese)
[ci skip]
2026-08-25 02:14:39 +01:00
Philip Okugbe 76f3412ae8 New translations translation.json (Italian)
[ci skip]
2026-08-25 02:14:38 +01:00
Philip Okugbe fd82037089 New translations translation.json (German)
[ci skip]
2026-08-25 02:14:36 +01:00
Philip Okugbe 24cf7177ba New translations translation.json (French)
[ci skip]
2026-08-25 02:14:34 +01:00
Philip Okugbe 9835b4038f New translations translation.json (Spanish)
[ci skip]
2026-08-25 02:14:32 +01:00
Philip Okugbe e1fe356d9b New translations translation.json (Korean)
[ci skip]
2026-08-25 02:14:30 +01:00
Philip Okugbe 4c10a99a1f New translations translation.json (Portuguese, Brazilian)
[ci skip]
2026-08-24 21:27:38 +01:00
Philip Okugbe d6db8d295c New translations translation.json (English)
[ci skip]
2026-08-24 21:27:37 +01:00
Philip Okugbe 4e7726839f New translations translation.json (Chinese Simplified)
[ci skip]
2026-08-24 21:27:35 +01:00
Philip Okugbe 94c8ade192 New translations translation.json (Ukrainian)
[ci skip]
2026-08-24 21:27:33 +01:00
Philip Okugbe a69d3a04f8 New translations translation.json (Russian)
[ci skip]
2026-08-24 21:27:31 +01:00
Philip Okugbe 849adc9075 New translations translation.json (Dutch)
[ci skip]
2026-08-24 21:27:29 +01:00
Philip Okugbe feb7b5ec3e New translations translation.json (Japanese)
[ci skip]
2026-08-24 21:27:27 +01:00
Philip Okugbe dd5474d4dc New translations translation.json (Italian)
[ci skip]
2026-08-24 21:27:25 +01:00
Philip Okugbe 17ba19f899 New translations translation.json (German)
[ci skip]
2026-08-24 21:27:23 +01:00
Philip Okugbe 88ecb82761 New translations translation.json (French)
[ci skip]
2026-08-24 21:27:21 +01:00
Philip Okugbe b56be10aab New translations translation.json (Spanish)
[ci skip]
2026-08-24 21:27:19 +01:00
Philip Okugbe 29cfcf8ca4 New translations translation.json (Korean)
[ci skip]
2026-08-24 21:27:17 +01:00
Philip Okugbe a12dce74be New translations translation.json (Russian)
[ci skip]
2026-08-17 22:17:25 +01:00
Philip Okugbe 07919754cf New translations translation.json (English)
[ci skip]
2026-08-17 17:24:48 +01:00
Philip Okugbe 0c1afc228f New translations translation.json (English)
[ci skip]
2026-08-17 12:40:46 +01:00
Philip Okugbe 9ef797110b New translations translation.json (Portuguese, Brazilian)
[ci skip]
2026-08-12 14:18:50 +01:00
Philip Okugbe 4f6790bace New translations translation.json (English)
[ci skip]
2026-08-12 14:18:49 +01:00
Philip Okugbe 127a5f0a4b New translations translation.json (Chinese Simplified)
[ci skip]
2026-08-12 14:18:47 +01:00
Philip Okugbe 68a853bad7 New translations translation.json (Ukrainian)
[ci skip]
2026-08-12 14:18:46 +01:00
Philip Okugbe 534ed109ed New translations translation.json (Russian)
[ci skip]
2026-08-12 14:18:44 +01:00
Philip Okugbe 4b5a218b99 New translations translation.json (Dutch)
[ci skip]
2026-08-12 14:18:42 +01:00
Philip Okugbe dc7b68f793 New translations translation.json (Japanese)
[ci skip]
2026-08-12 14:18:41 +01:00
Philip Okugbe e007a01ed7 New translations translation.json (Italian)
[ci skip]
2026-08-12 14:18:39 +01:00
Philip Okugbe 498bb0f5af New translations translation.json (German)
[ci skip]
2026-08-12 14:18:37 +01:00
Philip Okugbe db22d920db New translations translation.json (French)
[ci skip]
2026-08-12 14:18:36 +01:00
Philip Okugbe d771110b8b New translations translation.json (Spanish)
[ci skip]
2026-08-12 14:18:34 +01:00
Philip Okugbe 04794412cb New translations translation.json (Korean)
[ci skip]
2026-08-12 14:18:33 +01:00
Philip Okugbe 3012b8c9af New translations translation.json (Portuguese, Brazilian)
[ci skip]
2026-08-12 12:41:37 +01:00
Philip Okugbe 7b594cb6ea New translations translation.json (English)
[ci skip]
2026-08-12 12:41:35 +01:00
Philip Okugbe 104b77c144 New translations translation.json (Chinese Simplified)
[ci skip]
2026-08-12 12:41:34 +01:00
Philip Okugbe 6c002b7963 New translations translation.json (Ukrainian)
[ci skip]
2026-08-12 12:41:32 +01:00
Philip Okugbe 5fe3e8d38b New translations translation.json (Russian)
[ci skip]
2026-08-12 12:41:30 +01:00
Philip Okugbe 813c1774e3 New translations translation.json (Dutch)
[ci skip]
2026-08-12 12:41:29 +01:00
Philip Okugbe a766f8947b New translations translation.json (Japanese)
[ci skip]
2026-08-12 12:41:27 +01:00
Philip Okugbe dd2e1d0a3d New translations translation.json (Italian)
[ci skip]
2026-08-12 12:41:26 +01:00
Philip Okugbe 2e53bb04be New translations translation.json (German)
[ci skip]
2026-08-12 12:41:24 +01:00
Philip Okugbe c0de27bc16 New translations translation.json (French)
[ci skip]
2026-08-12 12:41:23 +01:00
Philip Okugbe 54979fd60a New translations translation.json (Spanish)
[ci skip]
2026-08-12 12:41:21 +01:00
Philip Okugbe ede0977b16 New translations translation.json (Korean)
[ci skip]
2026-08-12 12:41:20 +01:00
Philip Okugbe e4ad085689 New translations translation.json (Spanish)
[ci skip]
2026-08-11 07:36:34 +01:00
Philip Okugbe 1cddefbb0f New translations translation.json (Korean)
[ci skip]
2026-08-10 16:42:05 +01:00
Philip Okugbe fbef4e285a New translations translation.json (Korean)
[ci skip]
2026-08-10 15:16:40 +01:00
Philip Okugbe 4314541d72 New translations translation.json (Korean)
[ci skip]
2026-07-23 15:51:12 +01:00
Philip Okugbe bafb014001 New translations translation.json (French)
[ci skip]
2026-07-23 12:26:55 +01:00
Philip Okugbe 7fefae4025 New translations translation.json (Italian)
[ci skip]
2026-07-07 11:03:58 +01:00
150 changed files with 4266 additions and 6031 deletions
+1
View File
@@ -0,0 +1 @@
shamefully-hoist = true
+4 -3
View File
@@ -1,7 +1,7 @@
FROM node:26-slim AS base FROM node:22-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@11.15.1 RUN npm install -g pnpm@10.4.0
FROM base AS builder FROM base AS builder
@@ -34,6 +34,7 @@ COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-f
# Copy root package files # Copy root package files
COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/ COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches # Copy patches
COPY --from=builder /app/patches /app/patches COPY --from=builder /app/patches /app/patches
@@ -42,7 +43,7 @@ RUN chown -R node:node /app
USER node USER node
RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm RUN pnpm install --frozen-lockfile --prod
RUN mkdir -p /app/data/storage RUN mkdir -p /app/data/storage
+5 -5
View File
@@ -36,7 +36,7 @@
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.3", "@tanstack/react-virtual": "3.14.3",
"alfaaz": "1.1.0", "alfaaz": "1.1.0",
"axios": "1.18.1", "axios": "1.16.0",
"blueimp-load-image": "5.16.0", "blueimp-load-image": "5.16.0",
"clsx": "2.1.1", "clsx": "2.1.1",
"file-saver": "2.0.5", "file-saver": "2.0.5",
@@ -50,9 +50,9 @@
"katex": "0.16.40", "katex": "0.16.40",
"lowlight": "3.3.0", "lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0", "mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.16.1", "mermaid": "11.15.0",
"mitt": "3.0.1", "mitt": "3.0.1",
"nanoid": "3.3.18", "nanoid": "3.3.8",
"posthog-js": "1.391.2", "posthog-js": "1.391.2",
"react": "19.2.7", "react": "19.2.7",
"react-clear-modal": "^2.0.18", "react-clear-modal": "^2.0.18",
@@ -61,7 +61,7 @@
"react-error-boundary": "6.1.1", "react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0", "react-helmet-async": "3.0.0",
"react-i18next": "16.5.8", "react-i18next": "16.5.8",
"react-router-dom": "7.18.2", "react-router-dom": "7.18.0",
"semver": "7.7.4", "semver": "7.7.4",
"socket.io-client": "4.8.3", "socket.io-client": "4.8.3",
"zod": "4.3.6" "zod": "4.3.6"
@@ -86,7 +86,7 @@
"globals": "15.13.0", "globals": "15.13.0",
"jsdom": "25.0.0", "jsdom": "25.0.0",
"optics-ts": "2.4.1", "optics-ts": "2.4.1",
"postcss": "8.5.25", "postcss": "8.5.14",
"postcss-preset-mantine": "1.18.0", "postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1", "postcss-simple-vars": "7.0.1",
"prettier": "3.8.1", "prettier": "3.8.1",
@@ -294,6 +294,7 @@
"Export space": "Bereich exportieren", "Export space": "Bereich exportieren",
"Export {{type}}": "Exportiere {{type}}", "Export {{type}}": "Exportiere {{type}}",
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}", "File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
"Media": "Media",
"Align left": "Links ausrichten", "Align left": "Links ausrichten",
"Align right": "Rechts ausrichten", "Align right": "Rechts ausrichten",
"Align center": "Zentrieren", "Align center": "Zentrieren",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Horizontale Trennlinie einfügen", "Insert horizontal rule divider": "Horizontale Trennlinie einfügen",
"Page break": "Seitenumbruch", "Page break": "Seitenumbruch",
"Insert a page break for printing.": "Einen Seitenumbruch zum Drucken einfügen.", "Insert a page break for printing.": "Einen Seitenumbruch zum Drucken einfügen.",
"Footnote": "Fußnote",
"Insert a footnote reference.": "Einen Fußnotenverweis einfügen.",
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.", "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 video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
"Upload any audio from your device.": "Laden Sie beliebige Audiodateien von Ihrem Gerät hoch.", "Upload any audio from your device.": "Laden Sie beliebige Audiodateien von Ihrem Gerät hoch.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Upgrade Ihres Plans", "Upgrade your plan": "Upgrade Ihres Plans",
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz", "Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.", "Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "KI ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "KI ist in den kostenpflichtigen Docmost-Editionen verfügbar. Kontaktieren Sie sales@docmost.com.",
"AI & MCP": "KI & MCP", "AI & MCP": "KI & MCP",
"AI": "KI", "AI": "KI",
"MCP": "MCP", "MCP": "MCP",
@@ -1289,5 +1292,24 @@
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected" "{{count}} selected_other": "{{count}} selected",
"Compare": "Vergleichen",
"Compare versions": "Versionen vergleichen",
"Select version from {{date}}": "Version vom {{date}} auswählen",
"Version actions for {{date}}": "Versionsaktionen für {{date}}",
"Comparing {{newer}} and {{older}}": "{{newer}} und {{older}} werden verglichen",
"Exit compare": "Vergleich beenden",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -47,24 +47,24 @@
"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.", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
"Description": "Description", "Description": "Description",
"Details": "Details", "Details": "Details",
"e.g ACME": "e.g ACME", "e.g ACME": "e.g. ACME",
"e.g ACME Inc": "e.g ACME Inc", "e.g ACME Inc": "e.g. ACME Inc",
"e.g Developers": "e.g Developers", "e.g Developers": "e.g. Developers",
"e.g Group for developers": "e.g Group for developers", "e.g Group for developers": "e.g. Group for developers",
"e.g product": "e.g product", "e.g product": "e.g. product",
"e.g Product Team": "e.g Product Team", "e.g Product Team": "e.g. Product Team",
"e.g Sales": "e.g Sales", "e.g Sales": "e.g. Sales",
"e.g Space for product team": "e.g Space for product team", "e.g Space for product team": "e.g. Space for product team",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate", "e.g Space for sales team to collaborate": "e.g. Space for sales team to collaborate",
"Edit": "Edit", "Edit": "Edit",
"Read": "Read", "Read": "Read",
"Edit group": "Edit group", "Edit group": "Edit group",
"Email": "Email", "Email": "Email",
"Enter a strong password": "Enter a strong password", "Enter a strong password": "Enter a strong password",
"Enter valid email addresses separated by comma or space max_50": "Enter valid email addresses separated by comma or space [max: 50]", "Enter valid email addresses separated by comma or space max_50": "Enter valid email addresses separated by comma or space [max: 50]",
"enter valid emails addresses": "enter valid emails addresses", "enter valid emails addresses": "Enter valid email addresses",
"Enter your current password": "Enter your current password", "Enter your current password": "Enter your current password",
"enter your full name": "enter your full name", "enter your full name": "Enter your full name",
"Enter your new password": "Enter your new password", "Enter your new password": "Enter your new password",
"Enter your new preferred email": "Enter your new preferred email", "Enter your new preferred email": "Enter your new preferred email",
"Enter your password": "Enter your password", "Enter your password": "Enter your password",
@@ -112,7 +112,7 @@
"Import pages": "Import pages", "Import pages": "Import pages",
"Import pages & space settings": "Import pages & space settings", "Import pages & space settings": "Import pages & space settings",
"Importing pages": "Importing pages", "Importing pages": "Importing pages",
"invalid invitation link": "invalid invitation link", "invalid invitation link": "Invalid invitation link",
"Invitation signup": "Invitation signup", "Invitation signup": "Invitation signup",
"Invite by email": "Invite by email", "Invite by email": "Invite by email",
"Invite members": "Invite members", "Invite members": "Invite members",
@@ -223,7 +223,7 @@
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.", "Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
"Sidebar toggle": "Sidebar toggle", "Sidebar toggle": "Sidebar toggle",
"Comments": "Comments", "Comments": "Comments",
"404 page not found": "404 page not found", "404 page not found": "404! Page not found",
"Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.", "Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.",
"Take me back to homepage": "Take me back to homepage", "Take me back to homepage": "Take me back to homepage",
"Forgot password": "Forgot password", "Forgot password": "Forgot password",
@@ -294,6 +294,7 @@
"Export space": "Export space", "Export space": "Export space",
"Export {{type}}": "Export {{type}}", "Export {{type}}": "Export {{type}}",
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
"Media": "Media",
"Align left": "Align left", "Align left": "Align left",
"Align right": "Align right", "Align right": "Align right",
"Align center": "Align center", "Align center": "Align center",
@@ -422,7 +423,7 @@
"Insert current date": "Insert current date", "Insert current date": "Insert current date",
"Time": "Time", "Time": "Time",
"Insert current time": "Insert current time", "Insert current time": "Insert current time",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams", "Draw and sketch excalidraw diagrams": "Draw and sketch Excalidraw diagrams",
"Multiple": "Multiple", "Multiple": "Multiple",
"Turn into": "Turn into", "Turn into": "Turn into",
"Text align": "Text align", "Text align": "Text align",
@@ -563,7 +564,7 @@
"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.", "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
"Your new backup codes": "Your new backup codes", "Your new backup codes": "Your new backup codes",
"I've saved my backup codes": "I've saved my backup codes", "I've saved my backup codes": "I've saved my backup codes",
"Failed to setup MFA": "Failed to setup MFA", "Failed to setup MFA": "Failed to set up MFA",
"Setup & Verify": "Setup & Verify", "Setup & Verify": "Setup & Verify",
"Add to authenticator": "Add to authenticator", "Add to authenticator": "Add to authenticator",
"1. Scan this QR code with your authenticator app": "1. Scan this QR code with your authenticator app", "1. Scan this QR code with your authenticator app": "1. Scan this QR code with your authenticator app",
@@ -637,7 +638,7 @@
"Enterprise": "Enterprise", "Enterprise": "Enterprise",
"Download attachment": "Download attachment", "Download attachment": "Download attachment",
"Allowed email domains": "Allowed email domains", "Allowed email domains": "Allowed email domains",
"Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can signup via SSO.", "Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can sign up via SSO.",
"Enter valid domain names separated by comma or space": "Enter valid domain names separated by comma or space", "Enter valid domain names separated by comma or space": "Enter valid domain names separated by comma or space",
"Enforce two-factor authentication": "Enforce two-factor authentication", "Enforce two-factor authentication": "Enforce two-factor authentication",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Once enforced, all members must enable two-factor authentication to access the workspace.", "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.",
@@ -698,7 +699,7 @@
"Upgrade your plan": "Upgrade your plan", "Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license", "Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.", "Upgrade your license tier.": "Upgrade your license tier.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is available in the Docmost paid editions. Contact sales@docmost.com.",
"AI & MCP": "AI & MCP", "AI & MCP": "AI & MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -826,7 +827,7 @@
"Choose how this page should stay accurate.": "Choose how this page should stay accurate.", "Choose how this page should stay accurate.": "Choose how this page should stay accurate.",
"Recurring verification": "Recurring verification", "Recurring verification": "Recurring verification",
"Verifiers re-confirm this page on a schedule.": "Verifiers re-confirm this page on a schedule.", "Verifiers re-confirm this page on a schedule.": "Verifiers re-confirm this page on a schedule.",
"Re-verify on a schedule (e.g every 30 days )": "Re-verify on a schedule (e.g every 30 days )", "Re-verify on a schedule (e.g every 30 days )": "Re-verify on a schedule (e.g. every 30 days)",
"Page stays editable at all times": "Page stays editable at all times", "Page stays editable at all times": "Page stays editable at all times",
"Best for runbooks, FAQs, living documentation": "Best for runbooks, FAQs, living documentation", "Best for runbooks, FAQs, living documentation": "Best for runbooks, FAQs, living documentation",
"Approval workflow": "Approval workflow", "Approval workflow": "Approval workflow",
@@ -902,7 +903,7 @@
"Publish": "Publish", "Publish": "Publish",
"Security": "Security", "Security": "Security",
"Enforce SSO": "Enforce SSO", "Enforce SSO": "Enforce SSO",
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password.", "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password.",
"AI-generated content may not be accurate.": "AI-generated content may not be accurate.", "AI-generated content may not be accurate.": "AI-generated content may not be accurate.",
"AI Chat": "AI Chat", "AI Chat": "AI Chat",
"Analyze for insights": "Analyze for insights", "Analyze for insights": "Analyze for insights",
@@ -1248,7 +1249,7 @@
"Not started": "Not started", "Not started": "Not started",
"Number": "Number", "Number": "Number",
"One month ago": "One month ago", "One month ago": "One month ago",
"One month from now": "One month from now", "One month from now": "In one month",
"One week ago": "One week ago", "One week ago": "One week ago",
"One week from now": "One week from now", "One week from now": "One week from now",
"Open as page": "Open as page", "Open as page": "Open as page",
@@ -1302,5 +1303,13 @@
"Error loading attachments.": "Error loading attachments.", "Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.", "No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}", "Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}" "Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -294,6 +294,7 @@
"Export space": "Exportar espacio", "Export space": "Exportar espacio",
"Export {{type}}": "Exportar {{type}}", "Export {{type}}": "Exportar {{type}}",
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos", "File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
"Media": "Media",
"Align left": "Alinear a la izquierda", "Align left": "Alinear a la izquierda",
"Align right": "Alinear a la derecha", "Align right": "Alinear a la derecha",
"Align center": "Alinear al centro", "Align center": "Alinear al centro",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Insertar regla horizontal", "Insert horizontal rule divider": "Insertar regla horizontal",
"Page break": "Salto de página", "Page break": "Salto de página",
"Insert a page break for printing.": "Inserta un salto de página para imprimir.", "Insert a page break for printing.": "Inserta un salto de página para imprimir.",
"Footnote": "Nota al pie",
"Insert a footnote reference.": "Insertar una referencia de nota al pie.",
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.", "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 video from your device.": "Sube cualquier video desde tu dispositivo.",
"Upload any audio from your device.": "Sube cualquier audio desde tu dispositivo.", "Upload any audio from your device.": "Sube cualquier audio desde tu dispositivo.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Mejora tu plan", "Upgrade your plan": "Mejora tu plan",
"Available with a paid license": "Disponible con una licencia de pago", "Available with a paid license": "Disponible con una licencia de pago",
"Upgrade your license tier.": "Mejora el nivel de tu licencia.", "Upgrade your license tier.": "Mejora el nivel de tu licencia.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "La IA solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "La IA está disponible en las ediciones de pago de Docmost. Contacte con sales@docmost.com.",
"AI & MCP": "IA y MCP", "AI & MCP": "IA y MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -1186,8 +1189,8 @@
"Default value": "Valor predeterminado", "Default value": "Valor predeterminado",
"Delete property": "Eliminar propiedad", "Delete property": "Eliminar propiedad",
"Delete view": "Eliminar vista", "Delete view": "Eliminar vista",
"Delete {{count}} rows?_one": "Delete 1 row?", "Delete {{count}} rows?_one": "¿Eliminar 1 fila?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?", "Delete {{count}} rows?_other": "¿Eliminar {{count}} filas?",
"Descending": "Descendente", "Descending": "Descendente",
"Discard": "Descartar", "Discard": "Descartar",
"Doesn't contain": "No contiene", "Doesn't contain": "No contiene",
@@ -1286,8 +1289,27 @@
"Value": "Valor", "Value": "Valor",
"View updated for everyone": "Vista actualizada para todos", "View updated for everyone": "Vista actualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Tiene cambios no guardados. ¿Quiere descartarlos?", "You have unsaved changes. Do you want to discard them?": "Tiene cambios no guardados. ¿Quiere descartarlos?",
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1 fila eliminada",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}} filas eliminadas",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1 seleccionado",
"{{count}} selected_other": "{{count}} selected" "{{count}} selected_other": "{{count}} seleccionadas",
"Compare": "Comparar",
"Compare versions": "Comparar versiones",
"Select version from {{date}}": "Seleccionar la versión del {{date}}",
"Version actions for {{date}}": "Acciones de la versión del {{date}}",
"Comparing {{newer}} and {{older}}": "Comparando {{newer}} y {{older}}",
"Exit compare": "Salir de la comparación",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -294,6 +294,7 @@
"Export space": "Exporter l'espace", "Export space": "Exporter l'espace",
"Export {{type}}": "Exporter {{type}}", "Export {{type}}": "Exporter {{type}}",
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes", "File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
"Media": "Media",
"Align left": "Aligner à gauche", "Align left": "Aligner à gauche",
"Align right": "Aligner à droite", "Align right": "Aligner à droite",
"Align center": "Aligner au centre", "Align center": "Aligner au centre",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Insérer un séparateur de règle horizontale", "Insert horizontal rule divider": "Insérer un séparateur de règle horizontale",
"Page break": "Saut de page", "Page break": "Saut de page",
"Insert a page break for printing.": "Insérer un saut de page pour limpression.", "Insert a page break for printing.": "Insérer un saut de page pour limpression.",
"Footnote": "Note de bas de page",
"Insert a footnote reference.": "Insérer une référence de note de bas de page.",
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.", "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 video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
"Upload any audio from your device.": "Téléchargez n'importe quel fichier audio depuis votre appareil.", "Upload any audio from your device.": "Téléchargez n'importe quel fichier audio depuis votre appareil.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Mettez à niveau votre forfait", "Upgrade your plan": "Mettez à niveau votre forfait",
"Available with a paid license": "Disponible avec une licence payante", "Available with a paid license": "Disponible avec une licence payante",
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.", "Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA est disponible dans les éditions payantes de Docmost. Contactez sales@docmost.com.",
"AI & MCP": "IA & MCP", "AI & MCP": "IA & MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -1186,8 +1189,8 @@
"Default value": "Valeur par défaut", "Default value": "Valeur par défaut",
"Delete property": "Supprimer la propriété", "Delete property": "Supprimer la propriété",
"Delete view": "Supprimer la vue", "Delete view": "Supprimer la vue",
"Delete {{count}} rows?_one": "Delete 1 row?", "Delete {{count}} rows?_one": "Supprimer 1 ligne ?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?", "Delete {{count}} rows?_other": "Supprimer {{count}} lignes ?",
"Descending": "Décroissant", "Descending": "Décroissant",
"Discard": "Ignorer", "Discard": "Ignorer",
"Doesn't contain": "Ne contient pas", "Doesn't contain": "Ne contient pas",
@@ -1286,8 +1289,27 @@
"Value": "Valeur", "Value": "Valeur",
"View updated for everyone": "Vue mise à jour pour tout le monde", "View updated for everyone": "Vue mise à jour pour tout le monde",
"You have unsaved changes. Do you want to discard them?": "Vous avez des modifications non enregistrées. Voulez-vous les ignorer ?", "You have unsaved changes. Do you want to discard them?": "Vous avez des modifications non enregistrées. Voulez-vous les ignorer ?",
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1 ligne supprimée",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}} lignes supprimées",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1 sélectionné(e)",
"{{count}} selected_other": "{{count}} selected" "{{count}} selected_other": "{{count}} sélectionné(e)s",
"Compare": "Comparer",
"Compare versions": "Comparer les versions",
"Select version from {{date}}": "Sélectionner la version du {{date}}",
"Version actions for {{date}}": "Actions de version pour le {{date}}",
"Comparing {{newer}} and {{older}}": "Comparaison de {{newer}} et {{older}}",
"Exit compare": "Quitter la comparaison",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -205,7 +205,7 @@
"Templates": "Modelli", "Templates": "Modelli",
"Theme": "Tema", "Theme": "Tema",
"To change your email, you have to enter your password and new email.": "Per cambiare la tua email, devi inserire la tua password e la nuova email.", "To change your email, you have to enter your password and new email.": "Per cambiare la tua email, devi inserire la tua password e la nuova email.",
"Toggle full page width": "Attiva/disattiva larghezza completa della pagina", "Toggle full page width": "Attiva/disattiva larghezza intera della pagina",
"Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.", "Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.",
"untitled": "senza titolo", "untitled": "senza titolo",
"Untitled": "Senza titolo", "Untitled": "Senza titolo",
@@ -294,6 +294,7 @@
"Export space": "Esporta spazio", "Export space": "Esporta spazio",
"Export {{type}}": "Esporta {{type}}", "Export {{type}}": "Esporta {{type}}",
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}", "File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
"Media": "Media",
"Align left": "Allinea a sinistra", "Align left": "Allinea a sinistra",
"Align right": "Allinea a destra", "Align right": "Allinea a destra",
"Align center": "Allinea al centro", "Align center": "Allinea al centro",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Inserisci divisore di regola orizzontale", "Insert horizontal rule divider": "Inserisci divisore di regola orizzontale",
"Page break": "Interruzione di pagina", "Page break": "Interruzione di pagina",
"Insert a page break for printing.": "Inserisci un'interruzione di pagina per la stampa.", "Insert a page break for printing.": "Inserisci un'interruzione di pagina per la stampa.",
"Footnote": "Nota a piè di pagina",
"Insert a footnote reference.": "Inserisci un riferimento a una nota a piè di pagina.",
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.", "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 video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
"Upload any audio from your device.": "Carica qualsiasi audio dal tuo dispositivo.", "Upload any audio from your device.": "Carica qualsiasi audio dal tuo dispositivo.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Aggiorna il tuo piano", "Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento", "Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.", "Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA è disponibile nelle edizioni a pagamento di Docmost. Contatta sales@docmost.com.",
"AI & MCP": "IA e MCP", "AI & MCP": "IA e MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -1186,8 +1189,8 @@
"Default value": "Valore predefinito", "Default value": "Valore predefinito",
"Delete property": "Elimina proprietà", "Delete property": "Elimina proprietà",
"Delete view": "Elimina vista", "Delete view": "Elimina vista",
"Delete {{count}} rows?_one": "Delete 1 row?", "Delete {{count}} rows?_one": "Eliminare 1 riga?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?", "Delete {{count}} rows?_other": "Eliminare {{count}} righe?",
"Descending": "Decrescente", "Descending": "Decrescente",
"Discard": "Ignora", "Discard": "Ignora",
"Doesn't contain": "Non contiene", "Doesn't contain": "Non contiene",
@@ -1286,8 +1289,27 @@
"Value": "Valore", "Value": "Valore",
"View updated for everyone": "Vista aggiornata per tutti", "View updated for everyone": "Vista aggiornata per tutti",
"You have unsaved changes. Do you want to discard them?": "Hai modifiche non salvate. Vuoi ignorarle?", "You have unsaved changes. Do you want to discard them?": "Hai modifiche non salvate. Vuoi ignorarle?",
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1 riga eliminata",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}} righe eliminate",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1 selezionato",
"{{count}} selected_other": "{{count}} selected" "{{count}} selected_other": "{{count}} selezionati",
"Compare": "Confronta",
"Compare versions": "Confronta versioni",
"Select version from {{date}}": "Seleziona la versione del {{date}}",
"Version actions for {{date}}": "Azioni della versione del {{date}}",
"Comparing {{newer}} and {{older}}": "Confronto tra {{newer}} e {{older}}",
"Exit compare": "Esci dal confronto",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -294,6 +294,7 @@
"Export space": "エクスポートスペース", "Export space": "エクスポートスペース",
"Export {{type}}": "{{type}}をエクスポート", "Export {{type}}": "{{type}}をエクスポート",
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています", "File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
"Media": "Media",
"Align left": "左揃え", "Align left": "左揃え",
"Align right": "右揃え", "Align right": "右揃え",
"Align center": "中央揃え", "Align center": "中央揃え",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "区切り線を挿入します", "Insert horizontal rule divider": "区切り線を挿入します",
"Page break": "改ページ", "Page break": "改ページ",
"Insert a page break for printing.": "印刷用に改ページを挿入します。", "Insert a page break for printing.": "印刷用に改ページを挿入します。",
"Footnote": "脚注",
"Insert a footnote reference.": "脚注参照を挿入します。",
"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 audio from your device.": "デバイスから音声ファイルをアップロードします。", "Upload any audio from your device.": "デバイスから音声ファイルをアップロードします。",
@@ -696,7 +699,7 @@
"Upgrade your plan": "プランをアップグレードする", "Upgrade your plan": "プランをアップグレードする",
"Available with a paid license": "有料ライセンスで利用可能", "Available with a paid license": "有料ライセンスで利用可能",
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。", "Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI は Docmost の有料版で利用可能です。sales@docmost.com までお問い合わせください。",
"AI & MCP": "AI と MCP", "AI & MCP": "AI と MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -1289,5 +1292,24 @@
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected" "{{count}} selected_other": "{{count}} selected",
"Compare": "比較",
"Compare versions": "バージョンを比較",
"Select version from {{date}}": "{{date}} のバージョンを選択",
"Version actions for {{date}}": "{{date}} のバージョンの操作",
"Comparing {{newer}} and {{older}}": "{{newer}} と {{older}} を比較中",
"Exit compare": "比較を終了",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
+113 -91
View File
@@ -2,23 +2,23 @@
"Account": "계정", "Account": "계정",
"Active": "활성", "Active": "활성",
"Add": "추가", "Add": "추가",
"Add group members": "팀에 사용자 추가", "Add group members": "그룹에 멤버 추가",
"Add groups": "팀 생성", "Add groups": "그룹 추가",
"Add members": "사용자 추가", "Add members": "사용자 추가",
"Add to groups": "에 추가", "Add to groups": "그룹에 추가",
"Add space members": "Space에 사용자 추가", "Add space members": "Space에 사용자 추가",
"Add to favorites": "즐겨찾기에 추가", "Add to favorites": "즐겨찾기에 추가",
"Admin": "관리자", "Admin": "관리자",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "이 을 삭제하시겠습니까? 해당 팀에 속한 사용자들은이 가진 모든 권한을 잃게 됩니다.", "Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "이 그룹을 삭제하시겠습니까? 그룹 멤버는그룹이 가진 모든 권한을 잃게 됩니다.",
"Are you sure you want to delete this page?": "이 페이지를 삭제하시겠습니까?", "Are you sure you want to delete this page?": "이 페이지를 삭제하시겠습니까?",
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "이 사용자를 에서 제거하시겠습니까? 사용자는 이 이 가진 모든 권한을 잃게 됩니다.", "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "이 사용자를 그룹에서 제거하시겠습니까? 사용자는 이 그룹이 가진 모든 권한을 잃게 됩니다.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "이 사용자를 Space에서 제거하시겠습니까? 사용자는 이 Space에 대한 모든 접근 권한을 잃게 됩니다.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "이 사용자를 Space에서 제거하시겠습니까? 사용자는 이 Space에 대한 모든 접근 권한을 잃게 됩니다.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "이 버전으로 복원하시겠습니까? 저장되지 않은 모든 변경사항이 손실됩니다.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "이 버전으로 복원하시겠습니까? 저장되지 않은 모든 변경사항이 손실됩니다.",
"Can become members of groups and spaces in workspace": "Workspace 내 팀 및 Space의 사용자가 될 수 있습니다.", "Can become members of groups and spaces in workspace": "워크스페이스의 그룹 및 스페이스의 멤버가 될 수 있",
"Can create and edit pages in space.": "Space에 페이지를 생성하고 편집할 수 있습니다.", "Can create and edit pages in space.": "Space에 페이지를 생성하고 편집할 수 있습니다.",
"Can edit": "편집할 수 있음", "Can edit": "편집할 수 있음",
"Can manage workspace": "Workspace를 관리할 수 있음", "Can manage workspace": "워크스페이스를 관리할 수 있음",
"Can manage workspace but cannot delete it": "Workspace를 관리할 수 있지만, 삭제는 불가능.", "Can manage workspace but cannot delete it": "워크스페이스를 관리할 수 있지만, 삭제할 수 없음",
"Can view": "볼 수 있음", "Can view": "볼 수 있음",
"Can view pages in space but not edit.": "Space의 페이지를 볼 수 있지만, 편집은 불가능.", "Can view pages in space but not edit.": "Space의 페이지를 볼 수 있지만, 편집은 불가능.",
"Cancel": "취소", "Cancel": "취소",
@@ -33,17 +33,17 @@
"Copy as Markdown": "Markdown으로 복사", "Copy as Markdown": "Markdown으로 복사",
"Copy link": "링크 복사", "Copy link": "링크 복사",
"Create": "생성", "Create": "생성",
"Create group": " 생성", "Create group": "그룹 생성",
"Create page": "페이지 생성", "Create page": "페이지 생성",
"Create space": "Space 생성", "Create space": "Space 생성",
"Create workspace": "Workspace 생성", "Create workspace": "워크스페이스 생성",
"Current password": "현재 비밀번호", "Current password": "현재 비밀번호",
"Dark": "어두운", "Dark": "어두운",
"Date": "날짜", "Date": "날짜",
"Delete": "삭제", "Delete": "삭제",
"Remove from page": "페이지에서 제거", "Remove from page": "페이지에서 제거",
"Base options": "베이스 옵션", "Base options": "베이스 옵션",
"Delete group": " 삭제", "Delete group": "그룹 삭제",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "이 페이지를 삭제하시겠습니까? 하위 페이지와 페이지 기록이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "이 페이지를 삭제하시겠습니까? 하위 페이지와 페이지 기록이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
"Description": "설명", "Description": "설명",
"Details": "세부사항", "Details": "세부사항",
@@ -58,7 +58,7 @@
"e.g Space for sales team to collaborate": "예: 영업 팀이 협업하는 스페이스", "e.g Space for sales team to collaborate": "예: 영업 팀이 협업하는 스페이스",
"Edit": "편집", "Edit": "편집",
"Read": "읽기", "Read": "읽기",
"Edit group": " 편집", "Edit group": "그룹 편집",
"Email": "이메일", "Email": "이메일",
"Enter a strong password": "강력한 비밀번호를 입력하세요", "Enter a strong password": "강력한 비밀번호를 입력하세요",
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]", "Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
@@ -103,10 +103,10 @@
"Full page width": "전체 페이지 너비", "Full page width": "전체 페이지 너비",
"Full width": "전체 너비", "Full width": "전체 너비",
"General": "일반", "General": "일반",
"Group": "", "Group": "그룹",
"Group description": " 설명", "Group description": "그룹 설명",
"Group name": " 이름", "Group name": "그룹 이름",
"Groups": "", "Groups": "그룹",
"Has full access to space settings and pages.": "Space 설정과 페이지에 대한 전체 접근 권한이 있습니다.", "Has full access to space settings and pages.": "Space 설정과 페이지에 대한 전체 접근 권한이 있습니다.",
"Home": "홈", "Home": "홈",
"Import pages": "페이지 가져오기", "Import pages": "페이지 가져오기",
@@ -119,14 +119,14 @@
"Invite new members": "새 사용자 초대", "Invite new members": "새 사용자 초대",
"Invite People": "사용자 초대", "Invite People": "사용자 초대",
"Invited members who are yet to accept their invitation will appear here.": "초대를 아직 수락하지 않은 초대된 사용자가 여기에 표시됩니다.", "Invited members who are yet to accept their invitation will appear here.": "초대를 아직 수락하지 않은 초대된 사용자가 여기에 표시됩니다.",
"Invited members will be granted access to spaces the groups can access": "초대된 사용자는 팀이 접근할 수 있는 Space에 대한 접근 권한을 받게 됩니다", "Invited members will be granted access to spaces the groups can access": "초대된 멤버에게는 그룹이 접근할 수 있는 스페이스에 대한 액세스 권한이 부여됩니다",
"Join the workspace": "Workspace 참여", "Join the workspace": "워크스페이스 참여",
"Language": "언어", "Language": "언어",
"Light": "밝은", "Light": "밝은",
"Link copied": "링크 복사됨", "Link copied": "링크 복사됨",
"Login": "로그인", "Login": "로그인",
"Logout": "로그아웃", "Logout": "로그아웃",
"Manage Group": " 관리", "Manage Group": "그룹 관리",
"Manage members": "사용자 관리", "Manage members": "사용자 관리",
"member": "사용자", "member": "사용자",
"Member": "사용자", "Member": "사용자",
@@ -139,7 +139,7 @@
"New email": "새 이메일", "New email": "새 이메일",
"New page": "새 페이지", "New page": "새 페이지",
"New password": "새 비밀번호", "New password": "새 비밀번호",
"No group found": "그룹을 찾을 수 없습니다", "No group found": "그룹을 찾을 수 없",
"No page history saved yet.": "아직 저장된 페이지 기록이 없습니다.", "No page history saved yet.": "아직 저장된 페이지 기록이 없습니다.",
"No pages yet": "아직 페이지가 없습니다", "No pages yet": "아직 페이지가 없습니다",
"No shared pages": "공유된 페이지가 없습니다.", "No shared pages": "공유된 페이지가 없습니다.",
@@ -148,7 +148,7 @@
"Overview": "개요", "Overview": "개요",
"Owner": "소유자", "Owner": "소유자",
"page": "페이지", "page": "페이지",
"Page deleted successfully": "페이지 삭제 완료", "Page deleted successfully": "페이지 삭제",
"Page history": "페이지 기록", "Page history": "페이지 기록",
"Select version": "버전 선택", "Select version": "버전 선택",
"Highlight changes": "변경 사항 강조", "Highlight changes": "변경 사항 강조",
@@ -156,7 +156,7 @@
"Pages": "페이지", "Pages": "페이지",
"pages": "페이지", "pages": "페이지",
"Password": "비밀번호", "Password": "비밀번호",
"Password changed successfully": "비밀번호 변경 완료", "Password changed successfully": "비밀번호 변경",
"People": "사용자", "People": "사용자",
"Pending": "대기 중", "Pending": "대기 중",
"Please confirm your action": "작업을 확인해 주세요", "Please confirm your action": "작업을 확인해 주세요",
@@ -166,15 +166,15 @@
"Recently updated": "최근 업데이트", "Recently updated": "최근 업데이트",
"Remove": "제거", "Remove": "제거",
"Remove from favorites": "즐겨찾기에서 제거", "Remove from favorites": "즐겨찾기에서 제거",
"Remove group member": "팀에서 사용자 제거", "Remove group member": "그룹 멤버 제거",
"Remove space member": "Space에서 사용자 제거", "Remove space member": "Space에서 사용자 제거",
"Restore": "복원", "Restore": "복원",
"Role": "역할", "Role": "역할",
"Save": "저장", "Save": "저장",
"Search": "검색", "Search": "검색",
"Search for groups": " 검색", "Search for groups": "그룹 검색",
"Search for users": "사용자 검색", "Search for users": "사용자 검색",
"Search for users and groups": "사용자 및 검색", "Search for users and groups": "사용자 및 그룹 검색",
"Search...": "검색...", "Search...": "검색...",
"Select language": "언어 선택", "Select language": "언어 선택",
"Select role": "역할 선택", "Select role": "역할 선택",
@@ -199,8 +199,8 @@
"Search for spaces": "스페이스 검색", "Search for spaces": "스페이스 검색",
"Start typing to search...": "검색하려면 입력을 시작하세요...", "Start typing to search...": "검색하려면 입력을 시작하세요...",
"Status": "상태", "Status": "상태",
"Successfully imported": "가져오기에 성공했습니다", "Successfully imported": "가져오기 완료",
"Successfully restored": "복원에 성공했습니다", "Successfully restored": "복원 완료",
"System settings": "시스템 설정", "System settings": "시스템 설정",
"Templates": "템플릿", "Templates": "템플릿",
"Theme": "테마", "Theme": "테마",
@@ -209,7 +209,7 @@
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.", "Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
"untitled": "제목 없음", "untitled": "제목 없음",
"Untitled": "제목 없음", "Untitled": "제목 없음",
"Updated successfully": "성공적으로 업데이트되었습니다", "Updated successfully": "업데이트 완료",
"User": "사용자", "User": "사용자",
"Workspace": "워크스페이스", "Workspace": "워크스페이스",
"Workspace Name": "워크스페이스 이름", "Workspace Name": "워크스페이스 이름",
@@ -245,15 +245,15 @@
"Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?", "Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?",
"Delete chat": "채팅 삭제", "Delete chat": "채팅 삭제",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "Are you sure you want to delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Comment created successfully": "댓글 생성 완료", "Comment created successfully": "댓글 생성",
"Error creating comment": "댓글 생성 오류", "Error creating comment": "댓글 생성 오류",
"Comment updated successfully": "댓글 업데이트 완료", "Comment updated successfully": "댓글 업데이트",
"Failed to update comment": "댓글 업데이트 실패", "Failed to update comment": "댓글 업데이트 실패",
"Comment deleted successfully": "댓글 삭제 완료", "Comment deleted successfully": "댓글 삭제",
"Failed to delete comment": "댓글 삭제 실패", "Failed to delete comment": "댓글 삭제 실패",
"Comment resolved successfully": "댓글 처리 완료", "Comment resolved successfully": "댓글이 해결로 표시됨",
"Comment re-opened successfully": "댓글이 성공적으로 다시 열렸습니다", "Comment re-opened successfully": "댓글이 열린 상태로 표시됨",
"Comment unresolved successfully": "댓글 해결이 성공적으로 취소되었습니다", "Comment unresolved successfully": "댓글이 미해결로 표시됨",
"Failed to resolve comment": "댓글 처리 실패", "Failed to resolve comment": "댓글 처리 실패",
"Resolve comment": "댓글 해결", "Resolve comment": "댓글 해결",
"Unresolve comment": "댓글 해결 취소", "Unresolve comment": "댓글 해결 취소",
@@ -263,19 +263,19 @@
"Are you sure you want to unresolve this comment thread?": "이 댓글 스레드를 미해결로 변경하시겠습니까?", "Are you sure you want to unresolve this comment thread?": "이 댓글 스레드를 미해결로 변경하시겠습니까?",
"Resolved": "해결됨", "Resolved": "해결됨",
"No active comments.": "활성 댓글이 없습니다.", "No active comments.": "활성 댓글이 없습니다.",
"Revoke invitation": "초대 취소", "Revoke invitation": "초대 폐기",
"Revoke": "취소", "Revoke": "폐기",
"Don't": "하지 않음", "Don't": "하지 않음",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 취소하시겠습니까? 사용자가 Workspace에 참여할 수 없게 됩니다.", "Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 폐기하시겠습니까? 사용자가 워크스페이스에 참여할 수 없게 됩니다.",
"Resend invitation": "초대 재전송", "Resend invitation": "초대 재전송",
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.", "Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 워크스페이스에 참여할 수 있습니다.",
"Invite link": "초대 링크", "Invite link": "초대 링크",
"Copy": "복사", "Copy": "복사",
"Copy to space": "스페이스로 복사", "Copy to space": "스페이스로 복사",
"Copied": "복사됨", "Copied": "복사됨",
"Duplicate": "복제", "Duplicate": "복제",
"Select a user": "사용자 선택", "Select a user": "사용자 선택",
"Select a group": " 선택", "Select a group": "그룹 선택",
"Export all pages and attachments in this space.": "이 Space의 모든 페이지와 첨부파일을 내보냅니다.", "Export all pages and attachments in this space.": "이 Space의 모든 페이지와 첨부파일을 내보냅니다.",
"Delete space": "Space 삭제", "Delete space": "Space 삭제",
"Are you sure you want to delete this space?": "이 Space을 삭제하시겠습니까?", "Are you sure you want to delete this space?": "이 Space을 삭제하시겠습니까?",
@@ -290,10 +290,11 @@
"Export failed:": "내보내기 실패:", "Export failed:": "내보내기 실패:",
"export error": "내보내기 오류", "export error": "내보내기 오류",
"Export page": "페이지 내보내기", "Export page": "페이지 내보내기",
"Export successful": "내보내기 성공", "Export successful": "내보내기 완료",
"Export space": "Space 내보내기", "Export space": "Space 내보내기",
"Export {{type}}": "{{type}} 내보내기", "Export {{type}}": "{{type}} 내보내기",
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다", "File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
"Media": "Media",
"Align left": "왼쪽 정렬", "Align left": "왼쪽 정렬",
"Align right": "오른쪽 정렬", "Align right": "오른쪽 정렬",
"Align center": "가운데 정렬", "Align center": "가운데 정렬",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "가로 구분선 삽입", "Insert horizontal rule divider": "가로 구분선 삽입",
"Page break": "페이지 나누기", "Page break": "페이지 나누기",
"Insert a page break for printing.": "인쇄용 페이지 나누기를 삽입합니다.", "Insert a page break for printing.": "인쇄용 페이지 나누기를 삽입합니다.",
"Footnote": "각주",
"Insert a footnote reference.": "각주 참조를 삽입합니다.",
"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 audio from your device.": "기기에서 오디오를 업로드하세요.", "Upload any audio from your device.": "기기에서 오디오를 업로드하세요.",
@@ -447,12 +450,12 @@
"Today, {{time}}": "오늘, {{time}}", "Today, {{time}}": "오늘, {{time}}",
"Yesterday, {{time}}": "어제, {{time}}", "Yesterday, {{time}}": "어제, {{time}}",
"now": "지금", "now": "지금",
"Space created successfully": "스페이스가 성공적으로 생성되었습니다", "Space created successfully": "스페이스 생성됨",
"Space updated successfully": "스페이스가 성공적으로 업데이트되었습니다", "Space updated successfully": "스페이스 업데이트됨",
"Space deleted successfully": "스페이스가 성공적으로 삭제되었습니다", "Space deleted successfully": "스페이스 삭제됨",
"Members added successfully": "멤버가 성공적으로 추가되었습니다", "Members added successfully": "멤버 추가됨",
"Member removed successfully": "멤버가 성공적으로 제거되었습니다", "Member removed successfully": "멤버 삭제됨",
"Member role updated successfully": "멤버 역할이 성공적으로 업데이트되었습니다", "Member role updated successfully": "멤버 역할 업데이트됨",
"Created by: <b>{{creatorName}}</b>": "작성자: <b>{{creatorName}}</b>", "Created by: <b>{{creatorName}}</b>": "작성자: <b>{{creatorName}}</b>",
"Created at: {{time}}": "작성 시간: {{time}}", "Created at: {{time}}": "작성 시간: {{time}}",
"Edited by {{name}} {{time}}": "{{name}}님이 {{time}}에 편집함", "Edited by {{name}} {{time}}": "{{name}}님이 {{time}}에 편집함",
@@ -465,7 +468,7 @@
"Choose {{format}} file": "{{format}} 파일 선택", "Choose {{format}} file": "{{format}} 파일 선택",
"Reading": "읽기", "Reading": "읽기",
"Delete member": "멤버 삭제", "Delete member": "멤버 삭제",
"Member deleted successfully": "멤버가 성공적으로 삭제되었습니다", "Member deleted successfully": "멤버 삭제됨",
"Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Deactivate member": "멤버 비활성화", "Deactivate member": "멤버 비활성화",
"Activate member": "멤버 활성화", "Activate member": "멤버 활성화",
@@ -498,32 +501,32 @@
"Delete share": "공유 삭제", "Delete share": "공유 삭제",
"Are you sure you want to delete this shared link?": "이 공유 링크를 삭제하시겠습니까?", "Are you sure you want to delete this shared link?": "이 공유 링크를 삭제하시겠습니까?",
"Publicly shared pages from spaces you are a member of will appear here": "회원으로 속한 스페이스의 공개 공유 페이지가 여기에 표시됩니다", "Publicly shared pages from spaces you are a member of will appear here": "회원으로 속한 스페이스의 공개 공유 페이지가 여기에 표시됩니다",
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다", "Share deleted successfully": "공유 삭제됨",
"Share not found": "공유를 찾을 수 없습니다", "Share not found": "공유를 찾을 수 없습니다",
"Failed to share page": "페이지 공유에 실패했습니다", "Failed to share page": "페이지 공유에 실패했습니다",
"Disable public sharing": "공유 비활성화", "Disable public sharing": "공개 공유 비활성화",
"Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.", "Prevent members from sharing pages publicly.": "멤버 페이지를 공개적으로 공유하지 못하게 합니다.",
"Toggle public sharing": "공유 전환", "Toggle public sharing": "공개 공유 전환",
"Toggle space public sharing": "공간 공유 전환", "Toggle space public sharing": "스페이스 공개 공유 전환",
"Allow viewers to comment": "뷰어가 댓글을 달 수 있도록 허용", "Allow viewers to comment": "뷰어가 댓글을 달 수 있도록 허용",
"Allow viewers to add comments on pages in this space.": "이 공간 내 페이지에 뷰어가 댓글을 추가할 수 있도록 허용합니다.", "Allow viewers to add comments on pages in this space.": "이 공간 내 페이지에 뷰어가 댓글을 추가할 수 있도록 허용합니다.",
"Toggle viewer comments": "뷰어 댓글 전환", "Toggle viewer comments": "뷰어 댓글 전환",
"Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.", "Public sharing is disabled at the workspace level": "공개 공유가 워크스페이스 수준에서 비활성화됨",
"Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.", "Prevent pages in this space from being shared publicly.": "이 스페이스의 페이지가 공개 공유되지 않도록 합니다.",
"Page permissions": "페이지 권한},{", "Page permissions": "페이지 권한},{",
"Control who can view and edit individual pages. Available with an enterprise license.": "개별 페이지의 조회 및 편집 권한을 제어합니다. 엔터프라이즈 라이선스에서 이용 가능합니다.", "Control who can view and edit individual pages. Available with an enterprise license.": "개별 페이지의 조회 및 편집 권한을 제어합니다. 엔터프라이즈 라이선스에서 이용 가능합니다.",
"Enable public sharing": "공유 활성화", "Enable public sharing": "공개 공유 활성화",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공유를 활성화하시겠습니까? 멤버들이 페이지를 공개적으로 공유할 수 있게 됩니다.", "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공개 공유를 활성화하시겠습니까? 멤버 페이지를 공개적으로 공유할 수 있게 됩니다.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 워크스페이스의 모든 기존 공유 링크가 삭제됩니다.", "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "공개 공유를 비활성화하시겠습니까? 이 워크스페이스의 기존 공유 링크가 모두 삭제됩니다.",
"Are you sure you want to enable public sharing for this space?": "이 공간의 공유를 활성화하시겠습니까?", "Are you sure you want to enable public sharing for this space?": "이 스페이스에 대해 공개 공유를 활성화하시겠습니까?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 공간의 모든 기존 공유 링크가 삭제됩니다.", "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "공개 공유를 비활성화하시겠습니까? 이 스페이스의 기존 공유 링크가 모두 삭제됩니다.",
"Public sharing is disabled": "공유가 비활성화되었습니다.", "Public sharing is disabled": "공개 공유가 비활성화",
"Public sharing has been disabled at the workspace level.": "워크스페이스 수준에서 공유가 비활성화되었습니다.", "Public sharing has been disabled at the workspace level.": "공개 공유가 워크스페이스 수준에서 비활성화되었습니다.",
"Public sharing has been disabled for this space.": "이 공간의 공유가 비활성화되었습니다.", "Public sharing has been disabled for this space.": "이 스페이스의 공개 공유가 비활성화되었습니다.",
"Copy page": "페이지 복사", "Copy page": "페이지 복사",
"Copy page to a different space.": "다른 공간으로 페이지 복사하기.", "Copy page to a different space.": "다른 공간으로 페이지 복사하기.",
"Page copied successfully": "페이지가 성공적으로 복사되었습니다", "Page copied successfully": "페이지 복사됨",
"Page duplicated successfully": "페이지가 성공적으로 복제되었습니다", "Page duplicated successfully": "페이지 복제됨",
"Find": "찾기", "Find": "찾기",
"Not found": "찾을 수 없음", "Not found": "찾을 수 없음",
"Previous Match (Shift+Enter)": "이전 일치 항목 (Shift+Enter)", "Previous Match (Shift+Enter)": "이전 일치 항목 (Shift+Enter)",
@@ -539,13 +542,13 @@
"Error": "오류", "Error": "오류",
"Failed to disable MFA": "MFA 비활성화에 실패했습니다", "Failed to disable MFA": "MFA 비활성화에 실패했습니다",
"Disable two-factor authentication": "2단계 인증 비활성화", "Disable two-factor authentication": "2단계 인증 비활성화",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "이중 인증을 비활성화하면 계정의 보안이 낮아집니다. 로그인 시 비밀번호만 필요하게 됩니다.", "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "2단계 인증을 비활성화하면 계정의 보안이 낮아집니다. 로그인 시 비밀번호만 필요하게 됩니다.",
"Please enter your password to disable two-factor authentication:": "이중 인증 비활성화를 위해 비밀번호를 입력하세요:", "Please enter your password to disable two-factor authentication:": "2단계 인증 비활성화를 위해 비밀번호를 입력하세요:",
"Two-factor authentication has been enabled": "2단계 인증이 활성화되었습니다", "Two-factor authentication has been enabled": "2단계 인증이 활성화",
"Two-factor authentication has been disabled": "2단계 인증이 비활성화되었습니다", "Two-factor authentication has been disabled": "2단계 인증이 비활성화",
"2-step verification": "2단계 인증", "2-step verification": "2단계 인증",
"Protect your account with an additional verification layer when signing in.": "로그인 시 추가 인증 단계를 통해 계정을 보호하세요.", "Protect your account with an additional verification layer when signing in.": "로그인 시 추가 인증 단계를 통해 계정을 보호하세요.",
"Two-factor authentication is active on your account.": "이중 인증이 계정에 활성화되어 있습니다.", "Two-factor authentication is active on your account.": "2단계 인증이 계정에 활성화되어 있습니다.",
"Add 2FA method": "2FA 방법 추가", "Add 2FA method": "2FA 방법 추가",
"Backup codes": "백업 코드", "Backup codes": "백업 코드",
"Disable": "비활성화", "Disable": "비활성화",
@@ -575,13 +578,13 @@
"Save your backup codes": "백업 코드를 저장하세요", "Save your backup codes": "백업 코드를 저장하세요",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "인증 앱에 대한 접근 권한을 잃은 경우, 이 코드를 사용하여 귀하의 계정에 접근할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.", "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "인증 앱에 대한 접근 권한을 잃은 경우, 이 코드를 사용하여 귀하의 계정에 접근할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.",
"Print": "인쇄", "Print": "인쇄",
"Two-factor authentication has been set up. Please log in again.": "이중 인증이 설정되었습니다. 다시 로그인해 주세요.", "Two-factor authentication has been set up. Please log in again.": "2단계 인증이 설정되었습니다. 다시 로그인해 주세요.",
"Two-Factor authentication required": "2단계 인증 필요합니다", "Two-Factor authentication required": "2단계 인증 필요",
"Your workspace requires two-factor authentication for all users": "이 워크스페이스는 모든 사용자에게 2단계 인증을 요구합니다", "Your workspace requires two-factor authentication for all users": "이 워크스페이스는 모든 사용자에게 2단계 인증을 요구합니다",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스 접근을 계속하려면 이중 인증을 설정해야 합니다. 이는 계정에 추가 보안 계층을 추가합니다.", "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스에 계속 액세스하려면 2단계 인증을 설정해야 합니다. 이는 계정의 보안을 한층 강화합니다.",
"Set up two-factor authentication": "2단계 인증 설정", "Set up two-factor authentication": "2단계 인증 설정",
"Cancel and logout": "취소하고 로그아웃", "Cancel and logout": "취소하고 로그아웃",
"Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 이중 인증이 필요합니다. 계속하려면 설정해 주세요.", "Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 2단계 인증이 필요합니다. 계속하려면 설정해 주세요.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "인증앱에서 얻은 인증 코드를 요구하여 계정의 보안에 추가적인 계층을 추가합니다.", "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "인증앱에서 얻은 인증 코드를 요구하여 계정의 보안에 추가적인 계층을 추가합니다.",
"Password is required": "비밀번호는 필수입니다", "Password is required": "비밀번호는 필수입니다",
"Password must be at least 8 characters": "비밀번호는 8자 이상이어야 합니다", "Password must be at least 8 characters": "비밀번호는 8자 이상이어야 합니다",
@@ -590,10 +593,10 @@
"Enter the 6-digit code found in your authenticator app": "인증 앱에 표시된 6자리 코드를 입력하세요", "Enter the 6-digit code found in your authenticator app": "인증 앱에 표시된 6자리 코드를 입력하세요",
"Need help authenticating?": "인증에 도움이 필요하십니까?", "Need help authenticating?": "인증에 도움이 필요하십니까?",
"MFA QR Code": "MFA QR 코드", "MFA QR Code": "MFA QR 코드",
"Account created successfully. Please log in to set up two-factor authentication.": "계정이 성공적으로 생성되었습니다. 이중 인증을 설정하려면 로그인해 주세요.", "Account created successfully. Please log in to set up two-factor authentication.": "계정 생성됨. 2단계 인증을 설정하려면 로그인해 주세요.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 완료하세요.", "Password reset successful. Please log in with your new password and complete two-factor authentication.": "비밀번호 재설정. 새 비밀번호로 로그인하여 2단계 인증을 완료하세요.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 설정하세요.", "Password reset successful. Please log in with your new password to set up two-factor authentication.": "비밀번호 재설정. 새 비밀번호로 로그인하여 2단계 인증을 설정하세요.",
"Password reset was successful. Please log in with your new password.": "비밀번호 재설정이 성공적으로 완료되었습니다. 새 비밀번호로 로그인하세요.", "Password reset was successful. Please log in with your new password.": "비밀번호 재설정. 새 비밀번호로 로그인하세요.",
"Two-factor authentication": "2단계 인증", "Two-factor authentication": "2단계 인증",
"Use authenticator app instead": "대신 인증 앱 사용", "Use authenticator app instead": "대신 인증 앱 사용",
"Verify backup code": "백업 코드 확인", "Verify backup code": "백업 코드 확인",
@@ -615,7 +618,7 @@
"Permanently delete": "영구 삭제", "Permanently delete": "영구 삭제",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b>님이 {{time}}에 이 페이지를 휴지통으로 이동했습니다.", "<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b>님이 {{time}}에 이 페이지를 휴지통으로 이동했습니다.",
"Page moved to trash": "페이지가 휴지통으로 이동되었습니다", "Page moved to trash": "페이지가 휴지통으로 이동되었습니다",
"Page restored successfully": "페이지가 성공적으로 복원되었습니다", "Page restored successfully": "페이지 복원됨",
"Deleted by": "삭제한 사람", "Deleted by": "삭제한 사람",
"Deleted at": "삭제 시간", "Deleted at": "삭제 시간",
"Preview": "미리보기", "Preview": "미리보기",
@@ -638,7 +641,7 @@
"Only users with email addresses from these domains can signup via SSO.": "이 도메인의 이메일 주소를 가진 사용자만 SSO를 통해 가입할 수 있습니다.", "Only users with email addresses from these domains can signup via SSO.": "이 도메인의 이메일 주소를 가진 사용자만 SSO를 통해 가입할 수 있습니다.",
"Enter valid domain names separated by comma or space": "쉼표 또는 공백으로 구분된 유효한 도메인 이름을 입력하세요", "Enter valid domain names separated by comma or space": "쉼표 또는 공백으로 구분된 유효한 도메인 이름을 입력하세요",
"Enforce two-factor authentication": "2단계 인증 강제", "Enforce two-factor authentication": "2단계 인증 강제",
"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.": "시행되면 모든 멤버가 워크스페이스에 액세스하기 위해 2단계 인증을 활성화해야 합니다.",
"Toggle MFA enforcement": "MFA 강제 설정 전환", "Toggle MFA enforcement": "MFA 강제 설정 전환",
"Display name": "표시 이름", "Display name": "표시 이름",
"Allow signup": "가입 허용", "Allow signup": "가입 허용",
@@ -657,7 +660,7 @@
"Remove image": "이미지 제거", "Remove image": "이미지 제거",
"Failed to remove image": "이미지 제거 실패", "Failed to remove image": "이미지 제거 실패",
"Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.", "Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.",
"Image removed successfully": "이미지가 성공적으로 제거되었습니다", "Image removed successfully": "이미지 삭제됨",
"API key": "API 키", "API key": "API 키",
"API keys": "API 키", "API keys": "API 키",
"API management": "API 관리", "API management": "API 관리",
@@ -669,7 +672,7 @@
"Last use": "최근 사용", "Last use": "최근 사용",
"No API keys found": "API 키를 찾을 수 없습니다", "No API keys found": "API 키를 찾을 수 없습니다",
"No expiration": "유효기간 없음", "No expiration": "유효기간 없음",
"Revoked successfully": "성공적으로 취소되었습니다", "Revoked successfully": "폐기 완료",
"Select expiration date": "만료일 선택", "Select expiration date": "만료일 선택",
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.", "This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
"Update": "업데이트", "Update": "업데이트",
@@ -696,7 +699,7 @@
"Upgrade your plan": "요금제를 업그레이드하세요", "Upgrade your plan": "요금제를 업그레이드하세요",
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다", "Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.", "Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI는 Docmost 엔터프라이즈 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI는 Docmost 유료 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.",
"AI & MCP": "AI 및 MCP", "AI & MCP": "AI 및 MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -939,8 +942,8 @@
"Try again": "다시 시도", "Try again": "다시 시도",
"Untitled chat": "제목 없는 채팅", "Untitled chat": "제목 없는 채팅",
"What can I help you with?": "무엇을 도와드릴까요?", "What can I help you with?": "무엇을 도와드릴까요?",
"Are you sure you want to revoke this {{credential}}": "이 {{credential}}을 취소하시겠습니까?", "Are you sure you want to revoke this {{credential}}": "이 {{credential}}을(를) 폐기하시겠습니까?",
"Automatically provision users and groups from your identity provider via SCIM.": "SCIM을 통해 ID 공급자에서 사용자와 그룹을 자동으로 프로비저닝합니다.", "Automatically provision users and groups from your identity provider via SCIM.": "SCIM을 통해 ID 공급자로부터 사용자와 그룹을 자동으로 프로비저닝합니다.",
"Configure your identity provider with this URL to provision users and groups.": "사용자와 그룹을 프로비저닝할 수 있도록 이 URL로 ID 공급자를 구성하세요.", "Configure your identity provider with this URL to provision users and groups.": "사용자와 그룹을 프로비저닝할 수 있도록 이 URL로 ID 공급자를 구성하세요.",
"Create {{credential}}": "{{credential}} 만들기", "Create {{credential}}": "{{credential}} 만들기",
"{{credential}} created": "{{credential}} 생성됨", "{{credential}} created": "{{credential}} 생성됨",
@@ -953,7 +956,7 @@
"Important": "중요", "Important": "중요",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "지금 {{credential}}를 복사해 두세요. 다시는 볼 수 없습니다!", "Make sure to copy your {{credential}} now. You won't be able to see it again!": "지금 {{credential}}를 복사해 두세요. 다시는 볼 수 없습니다!",
"Never": "안 함", "Never": "안 함",
"Revoke {{credential}}": "{{credential}} 취소", "Revoke {{credential}}": "{{credential}} 폐기",
"SCIM endpoint URL": "SCIM 엔드포인트 URL", "SCIM endpoint URL": "SCIM 엔드포인트 URL",
"SCIM provisioning": "SCIM 프로비저닝", "SCIM provisioning": "SCIM 프로비저닝",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM이 활성화되어 있는 동안에는 SSO 그룹 동기화보다 SCIM이 우선 적용됩니다.", "SCIM takes precedence over SSO group sync while enabled.": "SCIM이 활성화되어 있는 동안에는 SSO 그룹 동기화보다 SCIM이 우선 적용됩니다.",
@@ -1186,8 +1189,8 @@
"Default value": "기본값", "Default value": "기본값",
"Delete property": "속성 삭제", "Delete property": "속성 삭제",
"Delete view": "보기 삭제", "Delete view": "보기 삭제",
"Delete {{count}} rows?_one": "Delete 1 row?", "Delete {{count}} rows?_one": "1개 행을 삭제하시겠습니까?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?", "Delete {{count}} rows?_other": "{{count}}개 행을 삭제하시겠습니까?",
"Descending": "내림차순", "Descending": "내림차순",
"Discard": "삭제", "Discard": "삭제",
"Doesn't contain": "포함하지 않음", "Doesn't contain": "포함하지 않음",
@@ -1286,8 +1289,27 @@
"Value": "값", "Value": "값",
"View updated for everyone": "보기가 모두에게 업데이트되었습니다", "View updated for everyone": "보기가 모두에게 업데이트되었습니다",
"You have unsaved changes. Do you want to discard them?": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?", "You have unsaved changes. Do you want to discard them?": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?",
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1개 행 삭제 완료",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}}개 행 삭제 완료",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1개 선택됨",
"{{count}} selected_other": "{{count}} selected" "{{count}} selected_other": "{{count}}개 선택됨",
"Compare": "비교",
"Compare versions": "버전 비교",
"Select version from {{date}}": "{{date}}의 버전 선택",
"Version actions for {{date}}": "{{date}} 버전 작업",
"Comparing {{newer}} and {{older}}": "{{newer}} 및 {{older}} 비교 중",
"Exit compare": "비교 종료",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -294,6 +294,7 @@
"Export space": "Exporteer ruimte", "Export space": "Exporteer ruimte",
"Export {{type}}": "Exporteer {{type}}", "Export {{type}}": "Exporteer {{type}}",
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}", "File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
"Media": "Media",
"Align left": "Links uitlijnen", "Align left": "Links uitlijnen",
"Align right": "Rechts uitlijnen", "Align right": "Rechts uitlijnen",
"Align center": "Centreren", "Align center": "Centreren",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Horizontale lijn invoegen", "Insert horizontal rule divider": "Horizontale lijn invoegen",
"Page break": "Pagina-einde", "Page break": "Pagina-einde",
"Insert a page break for printing.": "Voeg een pagina-einde in voor het afdrukken.", "Insert a page break for printing.": "Voeg een pagina-einde in voor het afdrukken.",
"Footnote": "Voetnoot",
"Insert a footnote reference.": "Voeg een voetnootverwijzing in.",
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.", "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 video from your device.": "Upload een video vanaf uw apparaat.",
"Upload any audio from your device.": "Upload een audio vanaf uw apparaat.", "Upload any audio from your device.": "Upload een audio vanaf uw apparaat.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Upgrade je abonnement", "Upgrade your plan": "Upgrade je abonnement",
"Available with a paid license": "Beschikbaar met een betaalde licentie", "Available with a paid license": "Beschikbaar met een betaalde licentie",
"Upgrade your license tier.": "Upgrade je licentieniveau.", "Upgrade your license tier.": "Upgrade je licentieniveau.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is beschikbaar in de betaalde edities van Docmost. Neem contact op met sales@docmost.com.",
"AI & MCP": "AI & MCP", "AI & MCP": "AI & MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -1289,5 +1292,24 @@
"{{count}} rows deleted_one": "1 rij verwijderd", "{{count}} rows deleted_one": "1 rij verwijderd",
"{{count}} rows deleted_other": "{{count}} rijen verwijderd", "{{count}} rows deleted_other": "{{count}} rijen verwijderd",
"{{count}} selected_one": "1 geselecteerd", "{{count}} selected_one": "1 geselecteerd",
"{{count}} selected_other": "{{count}} geselecteerd" "{{count}} selected_other": "{{count}} geselecteerd",
"Compare": "Vergelijken",
"Compare versions": "Versies vergelijken",
"Select version from {{date}}": "Selecteer versie van {{date}}",
"Version actions for {{date}}": "Versieacties voor {{date}}",
"Comparing {{newer}} and {{older}}": "{{newer}} en {{older}} vergelijken",
"Exit compare": "Vergelijken afsluiten",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -294,6 +294,7 @@
"Export space": "Exportar espaço", "Export space": "Exportar espaço",
"Export {{type}}": "Exportar para {{type}}", "Export {{type}}": "Exportar para {{type}}",
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}", "File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
"Media": "Media",
"Align left": "Alinhar à esquerda", "Align left": "Alinhar à esquerda",
"Align right": "Alinhar à direita", "Align right": "Alinhar à direita",
"Align center": "Alinhar ao centro", "Align center": "Alinhar ao centro",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Insira um divisor horizontal", "Insert horizontal rule divider": "Insira um divisor horizontal",
"Page break": "Quebra de página", "Page break": "Quebra de página",
"Insert a page break for printing.": "Insira uma quebra de página para impressão.", "Insert a page break for printing.": "Insira uma quebra de página para impressão.",
"Footnote": "Nota de rodapé",
"Insert a footnote reference.": "Inserir uma referência de nota de rodapé.",
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.", "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 video from your device.": "Envie qualquer vídeo do seu dispositivo.",
"Upload any audio from your device.": "Envie qualquer áudio do seu dispositivo.", "Upload any audio from your device.": "Envie qualquer áudio do seu dispositivo.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Faça upgrade do seu plano", "Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga", "Available with a paid license": "Disponível com uma licença paga",
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.", "Upgrade your license tier.": "Faça upgrade do seu nível de licença.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "A IA está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "A IA está disponível nas edições pagas do Docmost. Contate sales@docmost.com.",
"AI & MCP": "IA e MCP", "AI & MCP": "IA e MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -1289,5 +1292,24 @@
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected" "{{count}} selected_other": "{{count}} selected",
"Compare": "Comparar",
"Compare versions": "Comparar versões",
"Select version from {{date}}": "Selecionar versão de {{date}}",
"Version actions for {{date}}": "Ações da versão de {{date}}",
"Comparing {{newer}} and {{older}}": "Comparando {{newer}} e {{older}}",
"Exit compare": "Sair da comparação",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -294,6 +294,7 @@
"Export space": "Экспорт пространства", "Export space": "Экспорт пространства",
"Export {{type}}": "Экспорт {{type}}", "Export {{type}}": "Экспорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
"Media": "Media",
"Align left": "По левому краю", "Align left": "По левому краю",
"Align right": "По правому краю", "Align right": "По правому краю",
"Align center": "По центру", "Align center": "По центру",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Вставить горизонтальный разделитель", "Insert horizontal rule divider": "Вставить горизонтальный разделитель",
"Page break": "Разрыв страницы", "Page break": "Разрыв страницы",
"Insert a page break for printing.": "Вставить разрыв страницы для печати.", "Insert a page break for printing.": "Вставить разрыв страницы для печати.",
"Footnote": "Сноска",
"Insert a footnote reference.": "Вставить ссылку на сноску.",
"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 audio from your device.": "Загрузите любой аудиофайл с вашего устройства.", "Upload any audio from your device.": "Загрузите любой аудиофайл с вашего устройства.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Обновите свой тарифный план", "Upgrade your plan": "Обновите свой тарифный план",
"Available with a paid license": "Доступно с платной лицензией", "Available with a paid license": "Доступно с платной лицензией",
"Upgrade your license tier.": "Обновите уровень вашей лицензии.", "Upgrade your license tier.": "Обновите уровень вашей лицензии.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ИИ доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ИИ доступен в платных версиях Docmost. Свяжитесь по адресу sales@docmost.com.",
"AI & MCP": "ИИ и MCP", "AI & MCP": "ИИ и MCP",
"AI": "ИИ", "AI": "ИИ",
"MCP": "MCP", "MCP": "MCP",
@@ -1186,8 +1189,8 @@
"Default value": "Значение по умолчанию", "Default value": "Значение по умолчанию",
"Delete property": "Удалить свойство", "Delete property": "Удалить свойство",
"Delete view": "Удалить представление", "Delete view": "Удалить представление",
"Delete {{count}} rows?_one": "Delete 1 row?", "Delete {{count}} rows?_one": "Удалить 1 строку?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?", "Delete {{count}} rows?_other": "Удалить {{count}} строк?",
"Descending": "По убыванию", "Descending": "По убыванию",
"Discard": "Отменить", "Discard": "Отменить",
"Doesn't contain": "Не содержит", "Doesn't contain": "Не содержит",
@@ -1286,8 +1289,27 @@
"Value": "Значение", "Value": "Значение",
"View updated for everyone": "Представление обновлено для всех", "View updated for everyone": "Представление обновлено для всех",
"You have unsaved changes. Do you want to discard them?": "У вас есть несохранённые изменения. Хотите их отменить?", "You have unsaved changes. Do you want to discard them?": "У вас есть несохранённые изменения. Хотите их отменить?",
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1 строка удалена",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}} строк удалено",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1 выбрано",
"{{count}} selected_other": "{{count}} selected" "{{count}} selected_other": "{{count}} выбрано",
"Compare": "Сравнить",
"Compare versions": "Сравнить версии",
"Select version from {{date}}": "Выбрать версию от {{date}}",
"Version actions for {{date}}": "Действия с версией от {{date}}",
"Comparing {{newer}} and {{older}}": "Сравнение {{newer}} и {{older}}",
"Exit compare": "Выйти из режима сравнения",
"Search attachments...": "Поиск вложений...",
"Error loading attachments.": "Ошибка при загрузке вложений.",
"No attachments on this page yet.": "На этой странице пока нет вложений.",
"Uploaded by {{name}}": "Загружено пользователем {{name}}",
"Download {{name}}": "Скачать {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -294,6 +294,7 @@
"Export space": "Експорт простору", "Export space": "Експорт простору",
"Export {{type}}": "Експорт {{type}}", "Export {{type}}": "Експорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
"Media": "Media",
"Align left": "По лівому краю", "Align left": "По лівому краю",
"Align right": "По правому краю", "Align right": "По правому краю",
"Align center": "По центру", "Align center": "По центру",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Вставити горизонтальний роздільник", "Insert horizontal rule divider": "Вставити горизонтальний роздільник",
"Page break": "Розрив сторінки", "Page break": "Розрив сторінки",
"Insert a page break for printing.": "Вставте розрив сторінки для друку.", "Insert a page break for printing.": "Вставте розрив сторінки для друку.",
"Footnote": "Виноска",
"Insert a footnote reference.": "Вставити посилання на виноску.",
"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 audio from your device.": "Завантажте будь-який аудіофайл зі свого пристрою.", "Upload any audio from your device.": "Завантажте будь-який аудіофайл зі свого пристрою.",
@@ -696,7 +699,7 @@
"Upgrade your plan": "Оновіть свій тарифний план", "Upgrade your plan": "Оновіть свій тарифний план",
"Available with a paid license": "Доступно за платною ліцензією", "Available with a paid license": "Доступно за платною ліцензією",
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.", "Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ШІ доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ШІ доступний у платних редакціях Docmost. Зверніться до sales@docmost.com.",
"AI & MCP": "ШІ та MCP", "AI & MCP": "ШІ та MCP",
"AI": "ШІ", "AI": "ШІ",
"MCP": "MCP", "MCP": "MCP",
@@ -1289,5 +1292,24 @@
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected" "{{count}} selected_other": "{{count}} selected",
"Compare": "Порівняти",
"Compare versions": "Порівняти версії",
"Select version from {{date}}": "Виберіть версію від {{date}}",
"Version actions for {{date}}": "Дії з версією від {{date}}",
"Comparing {{newer}} and {{older}}": "Порівняння {{newer}} і {{older}}",
"Exit compare": "Вийти з режиму порівняння",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -294,6 +294,7 @@
"Export space": "导出空间", "Export space": "导出空间",
"Export {{type}}": "导出为 {{type}}", "Export {{type}}": "导出为 {{type}}",
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制", "File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
"Media": "Media",
"Align left": "靠左对齐", "Align left": "靠左对齐",
"Align right": "靠右对齐", "Align right": "靠右对齐",
"Align center": "居中对齐", "Align center": "居中对齐",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "插入水平分割线", "Insert horizontal rule divider": "插入水平分割线",
"Page break": "分页符", "Page break": "分页符",
"Insert a page break for printing.": "插入一个用于打印的分页符。", "Insert a page break for printing.": "插入一个用于打印的分页符。",
"Footnote": "脚注",
"Insert a footnote reference.": "插入脚注引用。",
"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 audio from your device.": "从您的设备上传任意音频文件。", "Upload any audio from your device.": "从您的设备上传任意音频文件。",
@@ -696,7 +699,7 @@
"Upgrade your plan": "升级您的方案", "Upgrade your plan": "升级您的方案",
"Available with a paid license": "需付费许可才可用", "Available with a paid license": "需付费许可才可用",
"Upgrade your license tier.": "升级您的许可等级。", "Upgrade your license tier.": "升级您的许可等级。",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI 在 Docmost 企业版中提供。请联系 sales@docmost.com。", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI 在 Docmost 付费版中提供。请联系 sales@docmost.com。",
"AI & MCP": "AI 与 MCP", "AI & MCP": "AI 与 MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -1289,5 +1292,24 @@
"{{count}} rows deleted_one": "已删除 1 行", "{{count}} rows deleted_one": "已删除 1 行",
"{{count}} rows deleted_other": "已删除 {{count}} 行", "{{count}} rows deleted_other": "已删除 {{count}} 行",
"{{count}} selected_one": "已选择 1 项", "{{count}} selected_one": "已选择 1 项",
"{{count}} selected_other": "已选择 {{count}} 项" "{{count}} selected_other": "已选择 {{count}} 项",
"Compare": "比较",
"Compare versions": "比较版本",
"Select version from {{date}}": "选择 {{date}} 的版本",
"Version actions for {{date}}": "{{date}} 的版本操作",
"Comparing {{newer}} and {{older}}": "正在比较 {{newer}} 和 {{older}}",
"Exit compare": "退出比较",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -1,45 +0,0 @@
import { describe, expect, it, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { HelmetProvider } from "react-helmet-async";
import { DocumentTitle } from "./document-title.tsx";
const renderTitle = (ui: React.ReactNode) =>
render(<HelmetProvider>{ui}</HelmetProvider>);
describe("DocumentTitle", () => {
beforeEach(() => {
document.head.innerHTML = "<title>Docmost</title>";
});
it("appends the app name", () => {
renderTitle(<DocumentTitle title="Home" />);
expect(document.title).toBe("Home - Docmost");
});
it("omits the app name when asked", () => {
renderTitle(<DocumentTitle title="My page" withAppName={false} />);
expect(document.title).toBe("My page");
});
it("falls back to the app name without a title", () => {
renderTitle(<DocumentTitle />);
expect(document.title).toBe("Docmost");
});
it("never renders an empty title", () => {
renderTitle(<DocumentTitle title="Spaces" />);
const titles = Array.from(document.querySelectorAll("head > title"));
expect(titles.every((node) => node.textContent !== "")).toBe(true);
});
it("renders extra head children", () => {
renderTitle(
<DocumentTitle title="Shared">
<meta name="robots" content="noindex" />
</DocumentTitle>,
);
expect(
document.querySelector('head > meta[name="robots"]')?.getAttribute("content"),
).toBe("noindex");
});
});
@@ -1,29 +0,0 @@
import React from "react";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
type DocumentTitleProps = {
title?: string;
withAppName?: boolean;
children?: React.ReactNode;
};
export function DocumentTitle({
title,
withAppName = true,
children,
}: DocumentTitleProps) {
const appName = getAppName();
let documentTitle = appName;
if (title) {
documentTitle = withAppName ? `${title} - ${appName}` : title;
}
return (
<Helmet>
<title>{documentTitle}</title>
{children}
</Helmet>
);
}
+4 -2
View File
@@ -1,15 +1,17 @@
import { Title, Text, Button, Container, Group } from "@mantine/core"; import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css"; import classes from "./error-404.module.css";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export function Error404() { export function Error404() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title={t("404 page not found")} /> <Helmet>
<title>{t("404 page not found")} - Docmost</title>
</Helmet>
<Container className={classes.root}> <Container className={classes.root}>
<Title className={classes.title}>{t("404 page not found")}</Title> <Title className={classes.title}>{t("404 page not found")}</Title>
<Text c="dimmed" size="lg" ta="center" className={classes.description}> <Text c="dimmed" size="lg" ta="center" className={classes.description}>
@@ -255,7 +255,6 @@ export default function ChatInput({
}, },
content: "", content: "",
editable: true, editable: true,
textDirection: "auto",
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: false,
autofocus: autofocus ? "end" : false, autofocus: autofocus ? "end" : false,
+5 -2
View File
@@ -1,3 +1,5 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react"; import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
@@ -13,7 +15,6 @@ import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AiSettings() { export default function AiSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -39,7 +40,9 @@ export default function AiSettings() {
return ( return (
<> <>
<DocumentTitle title="AI settings" /> <Helmet>
<title>AI settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("AI settings")} /> <SettingsTitle title={t("AI settings")} />
<Tabs color="dark" value={activeTab} onChange={handleTabChange}> <Tabs color="dark" value={activeTab} onChange={handleTabChange}>
@@ -15,14 +15,6 @@ export interface IAiSearchResponse {
}>; }>;
} }
export async function hintVectorCache(): Promise<void> {
try {
await api.post("/ai/vector-cache-hint");
} catch {
// best-effort cache hint
}
}
export async function aiAnswers( export async function aiAnswers(
params: IPageSearchParams, params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void, onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
@@ -1,9 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core"; import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppUrl } from "@/lib/config"; import { getAppName, getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table"; import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal"; import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal"; import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -16,7 +17,6 @@ import { IApiKey } from "@/ee/api-key";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function UserApiKeys() { export default function UserApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -49,7 +49,11 @@ export default function UserApiKeys() {
return ( return (
<> <>
<DocumentTitle title={t("API keys")} /> <Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} /> <SettingsTitle title={t("API keys")} />
@@ -1,7 +1,9 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core"; import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table"; import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal"; import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal"; import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -13,7 +15,6 @@ import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key"; import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx'; import useUserRole from '@/hooks/use-user-role.tsx';
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins"; import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceApiKeys() { export default function WorkspaceApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -46,7 +47,11 @@ export default function WorkspaceApiKeys() {
return ( return (
<> <>
<DocumentTitle title={t("API management")} /> <Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API management")} /> <SettingsTitle title={t("API management")} />
@@ -10,9 +10,11 @@ import {
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react"; import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { import {
@@ -24,7 +26,6 @@ import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels"; import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table"; import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role"; import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
type RetentionUnit = "days" | "months" | "years"; type RetentionUnit = "days" | "months" | "years";
@@ -96,7 +97,11 @@ export default function AuditLogs() {
return ( return (
<> <>
<DocumentTitle title={t("Audit log")} /> <Helmet>
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Audit log")} /> <SettingsTitle title={t("Audit log")} />
+5 -2
View File
@@ -1,3 +1,5 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import BillingPlans from "@/ee/billing/components/billing-plans.tsx"; import BillingPlans from "@/ee/billing/components/billing-plans.tsx";
import BillingTrial from "@/ee/billing/components/billing-trial.tsx"; import BillingTrial from "@/ee/billing/components/billing-trial.tsx";
@@ -7,7 +9,6 @@ import React from "react";
import BillingDetails from "@/ee/billing/components/billing-details.tsx"; import BillingDetails from "@/ee/billing/components/billing-details.tsx";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts"; import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Billing() { export default function Billing() {
const { data: billing, isError: isBillingError } = useBillingQuery(); const { data: billing, isError: isBillingError } = useBillingQuery();
@@ -19,7 +20,9 @@ export default function Billing() {
return ( return (
<> <>
<DocumentTitle title="Billing" /> <Helmet>
<title>Billing - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Billing" /> <SettingsTitle title="Billing" />
<BillingTrial /> <BillingTrial />
+5 -2
View File
@@ -1,3 +1,5 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react"; import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
@@ -7,7 +9,6 @@ import InstallationDetails from "@/ee/licence/components/installation-details.ts
import OssDetails from "@/ee/licence/components/oss-details.tsx"; import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai/index";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function License() { export default function License() {
const [entitlements] = useAtom(entitlementAtom); const [entitlements] = useAtom(entitlementAtom);
@@ -20,7 +21,9 @@ export default function License() {
return ( return (
<> <>
<DocumentTitle title="License" /> <Helmet>
<title>License - {getAppName()}</title>
</Helmet>
<SettingsTitle title="License" /> <SettingsTitle title="License" />
<ActivateLicenseForm /> <ActivateLicenseForm />
@@ -1,16 +1,17 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Group, MultiSelect, Select, Space, TextInput } from "@mantine/core"; import { Group, MultiSelect, Select, Space, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useVerificationListQuery } from "@/ee/page-verification/queries/page-verification-query"; import { useVerificationListQuery } from "@/ee/page-verification/queries/page-verification-query";
import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types"; import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types";
import VerificationListTable from "@/ee/page-verification/components/verification-list-table"; import VerificationListTable from "@/ee/page-verification/components/verification-list-table";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function VerifiedPages() { export default function VerifiedPages() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -67,7 +68,11 @@ export default function VerifiedPages() {
return ( return (
<> <>
<DocumentTitle title={t("Verified pages")} /> <Helmet>
<title>
{t("Verified pages")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Verified pages")} /> <SettingsTitle title={t("Verified pages")} />
+7 -2
View File
@@ -1,13 +1,18 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { CloudLoginForm } from "@/ee/components/cloud-login-form.tsx"; import { CloudLoginForm } from "@/ee/components/cloud-login-form.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CloudLogin() { export default function CloudLogin() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title={t("Login")} /> <Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<CloudLoginForm /> <CloudLoginForm />
</> </>
@@ -1,11 +1,14 @@
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx"; import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React from "react"; import React from "react";
import { DocumentTitle } from "@/components/ui/document-title.tsx"; import { getAppName } from "@/lib/config.ts";
export default function CreateWorkspace() { export default function CreateWorkspace() {
return ( return (
<> <>
<DocumentTitle title="Create Workspace" /> <Helmet>
<title>Create Workspace - {getAppName()}</title>
</Helmet>
<SetupWorkspaceForm /> <SetupWorkspaceForm />
</> </>
); );
@@ -1,4 +1,5 @@
import { isCloud } from "@/lib/config.ts"; import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import { import {
Alert, Alert,
@@ -36,7 +37,6 @@ import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types"; import { IScimToken } from "@/ee/scim/types/scim-token.types";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const SCIM_TOKEN_LIMIT = 5; const SCIM_TOKEN_LIMIT = 5;
@@ -64,7 +64,9 @@ export default function Security() {
return ( return (
<> <>
<DocumentTitle title="Security" /> <Helmet>
<title>Security - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("Security")} /> <SettingsTitle title={t("Security")} />
<EnforceMfa /> <EnforceMfa />
@@ -41,7 +41,6 @@ export default function ReadonlyTemplateEditor({
<EditorProvider <EditorProvider
editable={false} editable={false}
immediatelyRender={true} immediatelyRender={true}
textDirection="auto"
extensions={extensions} extensions={extensions}
content={template.content} content={template.content}
/> />
@@ -22,6 +22,8 @@ import { useTranslation } from "react-i18next";
import { useDisclosure, useWindowEvent } from "@mantine/hooks"; import { useDisclosure, useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useEditor, EditorContent } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import { templateExtensions } from "@/features/editor/extensions/extensions"; import { templateExtensions } from "@/features/editor/extensions/extensions";
import { import {
@@ -42,7 +44,6 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import classes from "./template-editor.module.css"; import classes from "./template-editor.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateEditor() { export default function TemplateEditor() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -87,7 +88,6 @@ export default function TemplateEditor() {
const editor = useEditor({ const editor = useEditor({
extensions: templateExtensions, extensions: templateExtensions,
content: "", content: "",
textDirection: "auto",
editorProps: { editorProps: {
scrollThreshold: 80, scrollThreshold: 80,
scrollMargin: 80, scrollMargin: 80,
@@ -247,7 +247,11 @@ export default function TemplateEditor() {
return ( return (
<> <>
<DocumentTitle title={t("Edit template")} /> <Helmet>
<title>
{t("Edit template")} - {getAppName()}
</title>
</Helmet>
{editorToolbarEnabled && editor && ( {editorToolbarEnabled && editor && (
<FixedToolbar editor={editor} templateMode /> <FixedToolbar editor={editor} templateMode />
@@ -13,9 +13,11 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import { import {
useGetTemplatesQuery, useGetTemplatesQuery,
useDeleteTemplateMutation, useDeleteTemplateMutation,
@@ -29,7 +31,6 @@ import useUserRole from "@/hooks/use-user-role";
import CreateTemplateModal from "@/ee/template/components/create-template-modal"; import CreateTemplateModal from "@/ee/template/components/create-template-modal";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateList() { export default function TemplateList() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -101,7 +102,11 @@ export default function TemplateList() {
return ( return (
<> <>
<DocumentTitle title={t("Templates")} /> <Helmet>
<title>
{t("Templates")} - {getAppName()}
</title>
</Helmet>
<Container size="900" pt="xl"> <Container size="900" pt="xl">
<Group justify="space-between" mb="xl"> <Group justify="space-between" mb="xl">
@@ -1,59 +0,0 @@
import { ThemeIcon } from "@mantine/core";
import {
IconFile,
IconFileTypeCsv,
IconFileTypeDocx,
IconFileTypePdf,
IconFileTypePpt,
IconFileTypeXls,
IconFileZip,
IconMovie,
IconMusic,
IconPhoto,
type Icon,
} from "@tabler/icons-react";
const EXT_ICONS: Record<string, { icon: Icon; color: string }> = {
".pdf": { icon: IconFileTypePdf, color: "red" },
".doc": { icon: IconFileTypeDocx, color: "blue" },
".docx": { icon: IconFileTypeDocx, color: "blue" },
".xls": { icon: IconFileTypeXls, color: "teal" },
".xlsx": { icon: IconFileTypeXls, color: "teal" },
".csv": { icon: IconFileTypeCsv, color: "teal" },
".ppt": { icon: IconFileTypePpt, color: "orange" },
".pptx": { icon: IconFileTypePpt, color: "orange" },
".zip": { icon: IconFileZip, color: "gray" },
".rar": { icon: IconFileZip, color: "gray" },
".7z": { icon: IconFileZip, color: "gray" },
".tar": { icon: IconFileZip, color: "gray" },
".gz": { icon: IconFileZip, color: "gray" },
};
const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [
{ prefix: "image/", icon: IconPhoto, color: "grape" },
{ prefix: "video/", icon: IconMovie, color: "violet" },
{ prefix: "audio/", icon: IconMusic, color: "pink" },
];
interface AttachmentFileIconProps {
fileExt?: string;
mimeType?: string;
}
export function AttachmentFileIcon({
fileExt,
mimeType,
}: AttachmentFileIconProps) {
const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined;
const byMime = mimeType
? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix))
: undefined;
const { icon: FileIcon, color } = byExt ??
byMime ?? { icon: IconFile, color: "gray" };
return (
<ThemeIcon variant="light" color={color} size={40} radius="md">
<FileIcon size={22} stroke={1.5} />
</ThemeIcon>
);
}
@@ -1,191 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Anchor,
Center,
Group,
Loader,
Modal,
ScrollArea,
Text,
Tooltip,
} from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { SearchInput } from "@/components/common/search-input.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx";
import { formatBytes } from "@/lib";
import { getFileUrl } from "@/lib/config.ts";
import { formattedDate } from "@/lib/time.ts";
interface PageAttachmentsModalProps {
pageId: string;
open: boolean;
onClose: () => void;
}
export default function PageAttachmentsModal({
pageId,
open,
onClose,
}: PageAttachmentsModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={open}
onClose={onClose}
title={t("Attachments")}
size={800}
closeButtonProps={{ "aria-label": t("Close") }}
>
<PageAttachmentsList pageId={pageId} />
</Modal>
);
}
function PageAttachmentsList({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const {
data,
isLoading,
isError,
isFetching,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageAttachmentsQuery(pageId, search);
const attachments = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
);
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetching) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetching]);
const handleSearch = useCallback((value: string) => setSearch(value), []);
return (
<>
<SearchInput
onSearch={handleSearch}
placeholder={t("Search attachments...")}
/>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : isError ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{t("Error loading attachments.")}
</Text>
</Center>
) : attachments.length === 0 ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{search
? t("No results found")
: t("No attachments on this page yet.")}
</Text>
</Center>
) : (
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
{attachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea.Autosize>
)}
</>
);
}
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
const { t } = useTranslation();
const fileUrl = getFileUrl(attachment.url);
return (
<Group wrap="nowrap" gap="md" py="sm" pr="xs">
<AttachmentFileIcon
fileExt={attachment.fileExt}
mimeType={attachment.mimeType}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Anchor
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
fw={500}
c="inherit"
truncate="end"
style={{ display: "block" }}
>
{attachment.fileName}
</Anchor>
<Text size="xs" c="dimmed" mt={2} truncate="end">
{formatBytes(Number(attachment.fileSize))}
{" · "}
{formattedDate(new Date(attachment.createdAt))}
</Text>
</div>
{attachment.creator && (
<Tooltip
label={t("Uploaded by {{name}}", { name: attachment.creator.name })}
withArrow
>
<CustomAvatar
avatarUrl={attachment.creator.avatarUrl}
name={attachment.creator.name}
size="sm"
/>
</Tooltip>
)}
<Tooltip label={t("Download attachment")} withArrow>
<ActionIcon
component="a"
href={fileUrl}
download={attachment.fileName}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
aria-label={t("Download {{name}}", { name: attachment.fileName })}
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
</Group>
);
}
@@ -1,25 +0,0 @@
import {
InfiniteData,
keepPreviousData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { IPagination } from "@/lib/types.ts";
export function usePageAttachmentsQuery(
pageId: string,
search?: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageAttachment>, unknown>> {
return useInfiniteQuery({
queryKey: ["page-attachments", pageId, search],
queryFn: ({ pageParam }) =>
getPageAttachments(pageId, { cursor: pageParam, query: search }),
enabled: !!pageId,
gcTime: 0,
placeholderData: keepPreviousData,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -3,17 +3,7 @@ import loadImage from "blueimp-load-image";
import { import {
AvatarIconType, AvatarIconType,
IAttachment, IAttachment,
IPageAttachment,
} from "@/features/attachments/types/attachment.types.ts"; } from "@/features/attachments/types/attachment.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getPageAttachments(
pageId: string,
params?: QueryParams,
): Promise<IPagination<IPageAttachment>> {
const req = await api.post("/pages/attachments", { pageId, ...params });
return req.data;
}
async function compressAndResizeIcon( async function compressAndResizeIcon(
file: File, file: File,
@@ -1,5 +1,4 @@
export { export {
getPageAttachments,
uploadIcon, uploadIcon,
uploadUserAvatar, uploadUserAvatar,
uploadSpaceIcon, uploadSpaceIcon,
@@ -15,15 +15,6 @@ export interface IAttachment {
deletedAt: string | null; deletedAt: string | null;
} }
export interface IPageAttachment extends IAttachment {
url: string;
creator: {
id: string;
name: string;
avatarUrl: string | null;
} | null;
}
export enum AvatarIconType { export enum AvatarIconType {
AVATAR = "avatar", AVATAR = "avatar",
SPACE_ICON = "space-icon", SPACE_ICON = "space-icon",
@@ -103,7 +103,6 @@ const CommentEditor = forwardRef(
}, },
content: defaultContent, content: defaultContent,
editable, editable,
textDirection: "auto",
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: false,
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
@@ -1,46 +0,0 @@
import {
HocuspocusProviderWebsocket,
WebSocketStatus,
} from "@hocuspocus/provider";
import { getCollaborationUrl } from "@/lib/config.ts";
const RELEASE_GRACE_MS = 5000;
let socket: HocuspocusProviderWebsocket | null = null;
let editorCount = 0;
let releaseTimer: ReturnType<typeof setTimeout> | null = null;
export function getCollabSocket(): HocuspocusProviderWebsocket {
if (!socket) {
socket = new HocuspocusProviderWebsocket({
url: getCollaborationUrl(),
autoConnect: false,
});
}
return socket;
}
export function acquireCollabSocket(): void {
editorCount++;
if (releaseTimer) {
clearTimeout(releaseTimer);
releaseTimer = null;
}
const collabSocket = getCollabSocket();
collabSocket.shouldConnect = true;
if (collabSocket.status === WebSocketStatus.Disconnected) {
collabSocket.connect();
}
}
export function releaseCollabSocket(): void {
editorCount--;
if (editorCount > 0) return;
if (releaseTimer) clearTimeout(releaseTimer);
releaseTimer = setTimeout(() => {
releaseTimer = null;
if (editorCount === 0) {
socket?.disconnect();
}
}, RELEASE_GRACE_MS);
}
@@ -6,7 +6,6 @@
z-index: 99; z-index: 99;
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 45px;
background: var(--mantine-color-body); background: var(--mantine-color-body);
border-bottom: 1px solid border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
@@ -31,6 +31,8 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
const workspace = useAtomValue(workspaceAtom); const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true; const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
if (!editor || !state) return null;
return ( return (
<> <>
<div <div
@@ -47,26 +49,22 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
<div className={classes.divider} /> <div className={classes.divider} />
</> </>
)} */} )} */}
{editor && state && ( <BlockTypeGroup editor={editor} />
<> <div className={classes.divider} />
<BlockTypeGroup editor={editor} /> <InlineMarksGroup editor={editor} state={state} />
<div className={classes.divider} /> <div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} /> <ColorGroup editor={editor} />
<div className={classes.divider} /> <div className={classes.divider} />
<ColorGroup editor={editor} /> <ListsGroup editor={editor} state={state} />
<div className={classes.divider} /> <div className={classes.divider} />
<ListsGroup editor={editor} state={state} /> <AlignmentGroup editor={editor} />
<div className={classes.divider} /> <div className={classes.divider} />
<AlignmentGroup editor={editor} /> <MediaGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} /> <div className={classes.divider} />
<MediaGroup editor={editor} templateMode={templateMode} /> <QuickInsertsGroup editor={editor} />
<div className={classes.divider} /> <MoreInsertsGroup editor={editor} templateMode={templateMode} />
<QuickInsertsGroup editor={editor} /> <div className={classes.divider} />
<MoreInsertsGroup editor={editor} templateMode={templateMode} /> <HistoryGroup editor={editor} state={state} />
<div className={classes.divider} />
<HistoryGroup editor={editor} state={state} />
</>
)}
</div> </div>
</div> </div>
<div className={classes.spacer} aria-hidden /> <div className={classes.spacer} aria-hidden />
@@ -12,7 +12,6 @@ import {
IconMathFunction, IconMathFunction,
IconRotate2, IconRotate2,
IconSitemap, IconSitemap,
IconSuperscript,
IconTable, IconTable,
IconTag, IconTag,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -271,12 +270,6 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
> >
{t("Math block")} {t("Math block")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconSuperscript size={16} />}
onClick={() => editor.chain().focus().addFootnote().run()}
>
{t("Footnote")}
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );
@@ -30,7 +30,6 @@ import {
IconTag, IconTag,
IconMoodSmile, IconMoodSmile,
IconRotate2, IconRotate2,
IconSuperscript,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@@ -178,16 +177,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(), editor.chain().focus().deleteRange(range).setPageBreak().run(),
}, },
{
title: "Footnote",
description: "Insert a footnote reference.",
searchTerms: ["footnote", "reference", "citation", "note"],
icon: IconSuperscript,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
editor.commands.addFootnote();
},
},
{ {
title: "Image", title: "Image",
description: "Upload any image from your device.", description: "Upload any image from your device.",
@@ -40,7 +40,6 @@ export default function TransclusionContent({ content }: Props) {
<EditorProvider <EditorProvider
editable={false} editable={false}
immediatelyRender={true} immediatelyRender={true}
textDirection="auto"
extensions={extensions} extensions={extensions}
content={content as any} content={content as any}
/> />
@@ -1,11 +1,9 @@
import { markInputRule } from "@tiptap/core"; import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code"; import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list"; import { TaskList, TaskItem } from "@tiptap/extension-list";
import { CharacterCount, UndoRedo } from "@tiptap/extensions"; import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
import { Placeholder } from "@/features/editor/extensions/placeholder";
import { Superscript } from "@tiptap/extension-superscript"; import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript"; import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography"; import { Typography } from "@tiptap/extension-typography";
@@ -64,9 +62,6 @@ import {
TransclusionReference, TransclusionReference,
TableView, TableView,
BaseEmbed as BaseEmbedNode, BaseEmbed as BaseEmbedNode,
Footnotes,
Footnote,
FootnoteReference,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -136,7 +131,6 @@ lowlight.register("scala", scala);
// @ts-ignore // @ts-ignore
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
document: false,
heading: false, heading: false,
undoRedo: false, undoRedo: false,
link: false, link: false,
@@ -148,9 +142,6 @@ export const mainExtensions = [
codeBlock: false, codeBlock: false,
code: false, code: false,
}), }),
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule. // Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character // The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule // before the opening backtick as part of the match, causing markInputRule
@@ -203,19 +194,16 @@ export const mainExtensions = [
return i18n.t("Toggle title"); return i18n.t("Toggle title");
} }
if (node.type.name === "paragraph") { if (node.type.name === "paragraph") {
const doc = editor.state.doc; const $pos = editor.state.doc.resolve(pos);
if (pos >= 0 && pos <= doc.content.size) { const parentName = $pos.parent.type.name;
const parentName = doc.resolve(pos).parent.type.name; if (
if ( parentName === "column" ||
parentName === "column" || parentName === "tableCell" ||
parentName === "tableCell" || parentName === "tableHeader" ||
parentName === "tableHeader" || parentName === "callout" ||
parentName === "callout" || parentName === "blockquote"
parentName === "blockquote" || ) {
parentName === "footnote" return i18n.t("Write...");
) {
return i18n.t("Write...");
}
} }
return i18n.t('Write anything. Enter "/" for commands'); return i18n.t('Write anything. Enter "/" for commands');
} }
@@ -426,9 +414,6 @@ export const mainExtensions = [
}).configure(), }).configure(),
Columns, Columns,
Column, Column,
Footnotes,
Footnote,
FootnoteReference,
AutoJoiner.configure({ AutoJoiner.configure({
elementsToJoin: [], elementsToJoin: [],
}), }),
@@ -1,64 +0,0 @@
import { isNodeEmpty } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { Placeholder as TiptapPlaceholder } from "@tiptap/extensions";
export const Placeholder = TiptapPlaceholder.extend({
addProseMirrorPlugins() {
const editor = this.editor;
const options = this.options;
const dataAttribute = `data-${options.dataAttribute || "placeholder"}`;
return [
new Plugin({
key: new PluginKey("docmostPlaceholder"),
props: {
decorations: (state) => {
if (options.showOnlyWhenEditable && !editor.isEditable) {
return null;
}
const { doc, selection } = state;
const { anchor } = selection;
const decorations: Decoration[] = [];
const isEmptyDoc = editor.isEmpty;
doc.descendants((node, pos) => {
if (!node.type.isTextblock) {
return options.includeChildren;
}
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize;
const isEmpty = !node.isLeaf && isNodeEmpty(node);
if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {
const emptyNodeClass =
typeof options.emptyNodeClass === "function"
? options.emptyNodeClass({ editor, node, pos, hasAnchor })
: options.emptyNodeClass;
const classes = [emptyNodeClass];
if (isEmptyDoc) {
classes.push(options.emptyEditorClass);
}
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(" "),
[dataAttribute]:
typeof options.placeholder === "function"
? options.placeholder({ editor, node, pos, hasAnchor })
: options.placeholder,
}),
);
}
return options.includeChildren;
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
@@ -0,0 +1,7 @@
import { getCollaborationUrl } from "@/lib/config.ts";
const useCollaborationURL = (): string => {
return getCollaborationUrl();
};
export default useCollaborationURL;
+159 -177
View File
@@ -2,22 +2,20 @@ import "@/features/editor/styles/index.css";
import React, { import React, {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { IndexeddbPersistence } from "y-indexeddb"; import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import { import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus, WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters, onStatelessParameters,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import {
HocuspocusProviderWebsocketComponent,
HocuspocusRoom,
useHocuspocusEvent,
useHocuspocusProvider,
} from "@hocuspocus/provider-react";
import { import {
Editor, Editor,
EditorContent, EditorContent,
@@ -30,6 +28,7 @@ import {
mainExtensions, mainExtensions,
} from "@/features/editor/extensions/extensions"; } from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { import {
currentPageEditModeAtom, currentPageEditModeAtom,
@@ -77,11 +76,6 @@ import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
acquireCollabSocket,
getCollabSocket,
releaseCollabSocket,
} from "@/features/editor/collab-socket";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@@ -97,80 +91,7 @@ export default function PageEditor({
canComment, canComment,
}: PageEditorProps) { }: PageEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); const collaborationURL = useCollaborationUrl();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const [socket] = useState(getCollabSocket);
const hasCollabToken = !!collabQuery?.token;
useEffect(() => {
if (!hasCollabToken) return;
acquireCollabSocket();
return () => releaseCollabSocket();
}, [hasCollabToken]);
const handleStateless = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const handleAuthenticationFailed = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
};
return (
<TransclusionLookupProvider>
{collabQuery?.token ? (
<HocuspocusProviderWebsocketComponent websocketProvider={socket}>
<HocuspocusRoom
name={`page.${pageId}`}
token={collabQuery.token}
flushDelay={500}
onStateless={handleStateless}
onAuthenticationFailed={handleAuthenticationFailed}
>
<CollabPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</HocuspocusRoom>
</HocuspocusProviderWebsocketComponent>
) : (
<StaticPageEditor content={content} ariaLabel={t("Page content")} />
)}
</TransclusionLookupProvider>
);
}
function CollabPageEditor({
pageId,
editable,
content,
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const provider = useHocuspocusProvider();
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null); const editorRef = useRef<Editor | null>(null);
@@ -191,6 +112,7 @@ function CollabPageEditor({
); );
const [, setYjsSynced] = useAtom(yjsSyncedAtom); const [, setYjsSynced] = useAtom(yjsSyncedAtom);
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility(); const documentState = useDocumentVisibility();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
@@ -201,24 +123,95 @@ function CollabPageEditor({
[isComponentMounted], [isComponentMounted],
); );
const { handleScrollTo } = useEditorScroll({ canScroll }); 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);
useEffect(() => { useEffect(() => {
const local = new IndexeddbPersistence( if (!providersRef.current) {
provider.configuration.name, const documentName = `page.${pageId}`;
provider.document, const ydoc = new Y.Doc();
); const local = new IndexeddbPersistence(documentName, ydoc);
local.on("synced", () => setIsLocalSynced(true)); const socket = new HocuspocusProviderWebsocket({
return () => { url: collaborationURL,
local.destroy(); });
}; const onLocalSyncedHandler = () => {
}, [provider]); setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
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,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
useHocuspocusEvent("synced", ({ state }) => setIsRemoteSynced(state)); local.on("synced", onLocalSyncedHandler);
useHocuspocusEvent("status", ({ status }) => setYjsConnectionStatus(status)); 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]);
// Only connect/disconnect on tab/idle, not destroy // Only connect/disconnect on tab/idle, not destroy
useEffect(() => { useEffect(() => {
const socket = provider.configuration.websocketProvider; if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
if ( if (
isIdle && isIdle &&
@@ -235,21 +228,28 @@ function CollabPageEditor({
resetIdle(); resetIdle();
socket.connect(); socket.connect();
} }
}, [isIdle, documentState, provider, resetIdle]); }, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => { const extensions = useMemo(() => {
if (!currentUser?.user) { if (!providersReady || !providersRef.current || !currentUser?.user) {
return mainExtensions; return mainExtensions;
} }
return [...mainExtensions, ...collabExtensions(provider, currentUser.user)]; const remoteProvider = providersRef.current.remote;
}, [provider, currentUser?.user]);
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [providersReady, currentUser?.user]);
const editor = useEditor( const editor = useEditor(
{ {
extensions, extensions,
editable, editable,
textDirection: "auto",
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: false,
editorProps: { editorProps: {
@@ -326,16 +326,6 @@ function CollabPageEditor({
[pageId, editable, extensions], [pageId, editable, extensions],
); );
useLayoutEffect(() => {
if (editor && !editor.isDestroyed) {
// @ts-ignore
setEditor(editor);
// @ts-ignore
editor.storage.pageId = pageId;
editorRef.current = editor;
}
}, [editor, pageId, setEditor]);
const editorIsEditable = useEditorState({ const editorIsEditable = useEditorState({
editor, editor,
selector: (ctx) => { selector: (ctx) => {
@@ -426,73 +416,65 @@ function CollabPageEditor({
} }
}, [yjsConnectionStatus, isSynced]); }, [yjsConnectionStatus, isSynced]);
if (showStatic) {
return <StaticPageEditor content={content} ariaLabel={t("Page content")} />;
}
return ( return (
<div className="editor-container" style={{ position: "relative" }}> <TransclusionLookupProvider>
<div ref={menuContainerRef}> {showStatic ? (
<EditorContent editor={editor} /> <EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": t("Page content"),
},
}}
/>
) : (
<div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
{editor && ( {editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} /> <SearchAndReplaceDialog editor={editor} editable={editable} />
)} )}
{editor && editorIsEditable && ( {editor && editorIsEditable && (
<div> <div>
<EditorAiMenu editor={editor} /> <EditorAiMenu editor={editor} />
<EditorLinkMenu editor={editor} /> <EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} /> <TableMenu editor={editor} />
<TableHandlesLayer editor={editor} /> <TableHandlesLayer editor={editor} />
<ImageMenu editor={editor} /> <ImageMenu editor={editor} />
<VideoMenu editor={editor} /> <VideoMenu editor={editor} />
<PdfMenu editor={editor} /> <PdfMenu editor={editor} />
<CalloutMenu editor={editor} /> <CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} /> <SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} /> <ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} /> <DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} /> <ColumnsMenu editor={editor} />
</div>
)}
{editor &&
!editorIsEditable &&
(editable || canComment) &&
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
)}
</div> </div>
)} <div
{editor && !editorIsEditable && (editable || canComment) && ( onClick={() => {
<ReadonlyBubbleMenu editor={editor} /> if (editor && !editor.isDestroyed) editor.commands.focus("end");
)} }}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />} style={{ paddingBottom: "20vh" }}
{showReadOnlyCommentPopup && ( ></div>
<CommentDialog editor={editor} pageId={pageId} readOnly /> </div>
)} )}
</div> </TransclusionLookupProvider>
<div
onClick={() => {
if (editor && !editor.isDestroyed) editor.commands.focus("end");
}}
style={{ paddingBottom: "20vh" }}
></div>
</div>
);
}
function StaticPageEditor({
content,
ariaLabel,
}: {
content: any;
ariaLabel: string;
}) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
); );
} }
@@ -85,7 +85,6 @@ export default function ReadonlyPageEditor({
<EditorProvider <EditorProvider
editable={false} editable={false}
immediatelyRender={true} immediatelyRender={true}
textDirection="auto"
extensions={titleExtensions} extensions={titleExtensions}
content={title} content={title}
></EditorProvider> ></EditorProvider>
@@ -94,7 +93,6 @@ export default function ReadonlyPageEditor({
<EditorProvider <EditorProvider
editable={false} editable={false}
immediatelyRender={true} immediatelyRender={true}
textDirection="auto"
extensions={extensions} extensions={extensions}
content={content} content={content}
onCreate={({ editor }) => { onCreate={({ editor }) => {
@@ -1,26 +0,0 @@
.ProseMirror sup a.footnote-ref {
color: var(--mantine-primary-color-filled);
text-decoration: none;
cursor: pointer;
font-weight: 600;
}
.ProseMirror sup:has(a.footnote-ref) {
padding: 0 1px;
}
.ProseMirror ol.footnotes {
margin-top: 2rem;
padding-top: 0.75rem;
font-size: 0.875rem;
color: var(--mantine-color-dimmed);
list-style-type: decimal;
}
.ProseMirror ol.footnotes:has(li) {
border-top: 1px solid var(--mantine-color-default-border);
}
.ProseMirror ol.footnotes li p {
margin: 0.15rem 0;
}
@@ -18,4 +18,3 @@
@import "./columns.css"; @import "./columns.css";
@import "./status.css"; @import "./status.css";
@import "./base-embed.css"; @import "./base-embed.css";
@import "./footnotes.css";
@@ -54,7 +54,7 @@
var(--mantine-color-dark-5) var(--mantine-color-dark-5)
); );
font-weight: bold; font-weight: bold;
text-align: start; text-align: left;
} }
.column-resize-handle { .column-resize-handle {
@@ -86,7 +86,6 @@ export function TitleEditor({
}, },
editable: editable, editable: editable,
content: title, content: title,
textDirection: "auto",
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: false,
editorProps: { editorProps: {
@@ -6,13 +6,4 @@ export const activeHistoryPrevIdAtom = atom<string>("");
export const highlightChangesAtom = atom<boolean>(true); export const highlightChangesAtom = atom<boolean>(true);
export type DiffCounts = { added: number; deleted: number; total: number }; export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom<DiffCounts | null>( export const diffCountsAtom = atom<DiffCounts | null>(null);
null as DiffCounts | null,
);
export type ComparePair = { newerId: string; olderId: string };
export const compareModeAtom = atom<boolean>(false);
export const compareSelectionAtom = atom<string[]>([]);
export const comparePairAtom = atom<ComparePair | null>(
null as ComparePair | null,
);
@@ -1,7 +1,7 @@
.history { .history {
display: flex; display: block;
align-items: center;
width: 100%; width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover { @mixin hover {
@@ -12,28 +12,6 @@
} }
} }
.historyButton {
flex: 1;
min-width: 0;
color: inherit;
}
.compareCheckbox {
padding-left: var(--mantine-spacing-xs);
}
.itemMenu {
opacity: 0;
margin-right: var(--mantine-spacing-xs);
}
.history:hover .itemMenu,
.history:focus-within .itemMenu,
.history.active .itemMenu,
.itemMenu[aria-expanded="true"] {
opacity: 1;
}
.historyEditor { .historyEditor {
:global(.ProseMirror) { :global(.ProseMirror) {
padding: 0 !important; padding: 0 !important;
@@ -99,8 +77,3 @@
flex: 1; flex: 1;
padding: rem(16px) rem(40px); padding: rem(16px) rem(40px);
} }
.compareBanner {
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
@@ -31,7 +31,6 @@ export function HistoryEditor({
const editor = useEditor({ const editor = useEditor({
extensions: mainExtensions, extensions: mainExtensions,
editable: false, editable: false,
textDirection: "auto",
}); });
useEffect(() => { useEffect(() => {
@@ -171,6 +170,7 @@ export function HistoryEditor({
} }
const total = addedCount + deletedCount; const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total }); setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({ editor.setOptions({
@@ -1,21 +1,10 @@
import { import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
Text,
Group,
UnstyledButton,
Avatar,
Tooltip,
ActionIcon,
Checkbox,
Menu,
} from "@mantine/core";
import { IconDots } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time"; import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css"; import classes from "./css/history.module.css";
import clsx from "clsx"; import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types"; import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react"; import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
const MAX_VISIBLE_AVATARS = 5; const MAX_VISIBLE_AVATARS = 5;
@@ -26,13 +15,6 @@ interface HistoryItemProps {
onHover?: (id: string, index: number) => void; onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void; onHoverEnd?: () => void;
isActive: boolean; isActive: boolean;
compareMode: boolean;
isChecked: boolean;
isCheckboxDisabled: boolean;
canCompare: boolean;
onToggleCompare: (id: string) => void;
onStartCompare: (id: string) => void;
onRestore?: (id: string, index: number) => void;
} }
const HistoryItem = memo(function HistoryItem({ const HistoryItem = memo(function HistoryItem({
@@ -42,24 +24,10 @@ const HistoryItem = memo(function HistoryItem({
onHover, onHover,
onHoverEnd, onHoverEnd,
isActive, isActive,
compareMode,
isChecked,
isCheckboxDisabled,
canCompare,
onToggleCompare,
onStartCompare,
onRestore,
}: HistoryItemProps) { }: HistoryItemProps) {
const { t } = useTranslation();
const date = formattedDate(new Date(historyItem.createdAt));
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (compareMode) { onSelect(historyItem.id, index);
onToggleCompare(historyItem.id); }, [onSelect, historyItem.id, index]);
} else {
onSelect(historyItem.id, index);
}
}, [compareMode, onToggleCompare, onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index); onHover?.(historyItem.id, index);
@@ -69,115 +37,63 @@ const HistoryItem = memo(function HistoryItem({
const hasContributors = contributors && contributors.length > 0; const hasContributors = contributors && contributors.length > 0;
return ( return (
<div <UnstyledButton
className={clsx(classes.history, { [classes.active]: isActive })} p="xs"
onClick={handleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd} onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
> >
{compareMode && ( <Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<Checkbox
size="xs"
className={classes.compareCheckbox}
checked={isChecked}
disabled={isCheckboxDisabled}
onChange={() => onToggleCompare(historyItem.id)}
aria-label={t("Select version from {{date}}", { date })}
/>
)}
<UnstyledButton <Group gap={6} wrap="nowrap" mt={4}>
p="xs" {hasContributors ? (
onClick={handleClick} <>
className={classes.historyButton} <Tooltip.Group openDelay={300} closeDelay={100}>
> <Avatar.Group spacing={8}>
<Text size="sm">{date}</Text> {contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
<Tooltip key={contributor.id} label={contributor.name} withArrow>
<Group gap={6} wrap="nowrap" mt={4}> <CustomAvatar
{hasContributors ? ( size="sm"
<> avatarUrl={contributor.avatarUrl}
<Tooltip.Group openDelay={300} closeDelay={100}> name={contributor.name}
<Avatar.Group spacing={8}> />
{contributors </Tooltip>
.slice(0, MAX_VISIBLE_AVATARS) ))}
.map((contributor) => ( {contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip <Tooltip
key={contributor.id} withArrow
label={contributor.name} label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
withArrow <div key={c.id}>{c.name}</div>
>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))} ))}
{contributors.length > MAX_VISIBLE_AVATARS && ( >
<Tooltip <Avatar size="sm" color="gray">
withArrow +{contributors.length - MAX_VISIBLE_AVATARS}
label={contributors </Avatar>
.slice(MAX_VISIBLE_AVATARS) </Tooltip>
.map((c) => ( )}
<div key={c.id}>{c.name}</div> </Avatar.Group>
))} </Tooltip.Group>
> {contributors.length === 1 && (
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name}
</Text>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name} {contributors[0].name}
</Text> </Text>
</>
)}
</Group>
</UnstyledButton>
{!compareMode && (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
className={classes.itemMenu}
aria-label={t("Version actions for {{date}}", { date })}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
disabled={!canCompare}
onClick={() => onStartCompare(historyItem.id)}
>
{t("Compare")}
</Menu.Item>
{onRestore && (
<Menu.Item onClick={() => onRestore(historyItem.id, index)}>
{t("Restore")}
</Menu.Item>
)} )}
</Menu.Dropdown> </>
</Menu> ) : (
)} <>
</div> <CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</UnstyledButton>
); );
}); });
@@ -6,12 +6,8 @@ import HistoryItem from "@/features/page-history/components/history-item";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
historyAtoms, historyAtoms,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair";
import { useAtom, useSetAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { import {
@@ -36,9 +32,6 @@ function HistoryList({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms); const setHistoryModalOpen = useSetAtom(historyAtoms);
const [compareMode, setCompareMode] = useAtom(compareModeAtom);
const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom);
const setComparePair = useSetAtom(comparePairAtom);
const { const {
data: pageHistoryData, data: pageHistoryData,
@@ -86,58 +79,10 @@ function HistoryList({ pageId }: Props) {
const handleSelect = useCallback( const handleSelect = useCallback(
(id: string, index: number) => { (id: string, index: number) => {
setComparePair(null);
setActiveHistoryId(id); setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
}, },
[historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair], [historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
const handleToggleCompare = useCallback(
(id: string) => {
setCompareSelection((prev) => {
if (prev.includes(id)) return prev.filter((item) => item !== id);
if (prev.length >= 2) return prev;
return [...prev, id];
});
},
[setCompareSelection],
);
const handleStartCompare = useCallback(
(id: string) => {
setComparePair(null);
setCompareMode(true);
setCompareSelection([id]);
},
[setComparePair, setCompareMode, setCompareSelection],
);
const handleCancelCompare = useCallback(() => {
setCompareMode(false);
setCompareSelection([]);
}, [setCompareMode, setCompareSelection]);
const handleConfirmCompare = useCallback(() => {
const pair = resolveComparePair(historyItems, compareSelection);
if (!pair) return;
setComparePair(pair);
setCompareMode(false);
setCompareSelection([]);
}, [
historyItems,
compareSelection,
setComparePair,
setCompareMode,
setCompareSelection,
]);
const handleRestoreItem = useCallback(
(id: string, index: number) => {
handleSelect(id, index);
confirmRestore(id);
},
[handleSelect, confirmRestore],
); );
useEffect(() => { useEffect(() => {
@@ -193,16 +138,6 @@ function HistoryList({ pageId }: Props) {
onHover={handleHover} onHover={handleHover}
onHoverEnd={clearPrefetchTimeout} onHoverEnd={clearPrefetchTimeout}
isActive={historyItem.id === activeHistoryId} isActive={historyItem.id === activeHistoryId}
compareMode={compareMode}
isChecked={compareSelection.includes(historyItem.id)}
isCheckboxDisabled={
!compareSelection.includes(historyItem.id) &&
compareSelection.length >= 2
}
canCompare={historyItems.length >= 2}
onToggleCompare={handleToggleCompare}
onStartCompare={handleStartCompare}
onRestore={canRestore ? handleRestoreItem : undefined}
/> />
))} ))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />} {hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
@@ -213,44 +148,22 @@ function HistoryList({ pageId }: Props) {
)} )}
</ScrollArea> </ScrollArea>
{compareMode ? ( {canRestore && (
<> <>
<Divider /> <Divider />
<Group p="xs" wrap="nowrap"> <Group p="xs" wrap="nowrap">
<Button <Button
variant="default" variant="default"
size="compact-md" size="compact-md"
onClick={handleCancelCompare} onClick={() => setHistoryModalOpen(false)}
> >
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button <Button size="compact-md" onClick={confirmRestore}>
size="compact-md" {t("Restore")}
disabled={compareSelection.length !== 2}
onClick={handleConfirmCompare}
>
{t("Compare")}
</Button> </Button>
</Group> </Group>
</> </>
) : (
canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
<Button size="compact-md" onClick={() => confirmRestore()}>
{t("Restore")}
</Button>
</Group>
</>
)
)} )}
</div> </div>
); );
@@ -1,6 +1,5 @@
import { import {
ActionIcon, ActionIcon,
CloseButton,
Group, Group,
Paper, Paper,
ScrollArea, ScrollArea,
@@ -13,20 +12,17 @@ import { useAtom, useAtomValue } from "jotai";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
comparePairAtom,
diffCountsAtom, diffCountsAtom,
highlightChangesAtom, highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view"; import HistoryView from "@/features/page-history/components/history-view";
import { useMemo, useRef } from "react"; import { useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react"; import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
useDiffNavigation, useDiffNavigation,
useHistoryReset, useHistoryReset,
} from "@/features/page-history/hooks"; } from "@/features/page-history/hooks";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { formattedDate } from "@/lib/time";
interface Props { interface Props {
pageId: string; pageId: string;
@@ -40,28 +36,6 @@ export default function HistoryModalBody({ pageId }: Props) {
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom); const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom); const diffCounts = useAtomValue(diffCountsAtom);
const [comparePair, setComparePair] = useAtom(comparePairAtom);
const { data: pageHistoryData } = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const compareLabel = useMemo(() => {
if (!comparePair) return null;
const newerItem = historyItems.find(
(item) => item.id === comparePair.newerId,
);
const olderItem = historyItems.find(
(item) => item.id === comparePair.olderId,
);
if (!newerItem || !olderItem) return null;
return t("Comparing {{newer}} and {{older}}", {
newer: formattedDate(new Date(newerItem.createdAt)),
older: formattedDate(new Date(olderItem.createdAt)),
});
}, [comparePair, historyItems, t]);
useHistoryReset(pageId); useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } = const { currentChangeIndex, handlePrevChange, handleNextChange } =
@@ -76,25 +50,6 @@ export default function HistoryModalBody({ pageId }: Props) {
</nav> </nav>
<div style={{ position: "relative", flex: 1 }}> <div style={{ position: "relative", flex: 1 }}>
{comparePair && (
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={4}
className={classes.compareBanner}
>
<Text size="sm" fw={500} lineClamp={1}>
{compareLabel ?? t("Compare versions")}
</Text>
<CloseButton
size="sm"
aria-label={t("Exit compare")}
onClick={() => setComparePair(null)}
/>
</Group>
)}
<ScrollArea <ScrollArea
h={650} h={650}
w="100%" w="100%"
@@ -102,18 +57,11 @@ export default function HistoryModalBody({ pageId }: Props) {
viewportRef={scrollViewportRef} viewportRef={scrollViewportRef}
> >
<div className={classes.sidebarRightSection}> <div className={classes.sidebarRightSection}>
{comparePair ? ( {activeHistoryId && <HistoryView />}
<HistoryView
historyId={comparePair.newerId}
prevHistoryId={comparePair.olderId}
/>
) : (
activeHistoryId && <HistoryView />
)}
</div> </div>
</ScrollArea> </ScrollArea>
{(comparePair || (activeHistoryId && activeHistoryPrevId)) && ( {activeHistoryId && activeHistoryPrevId && (
<Paper <Paper
shadow="md" shadow="md"
radius="xl" radius="xl"
@@ -166,7 +166,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
<Button variant="default" onClick={() => setHistoryModalOpen(false)}> <Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={() => confirmRestore()}>{t("Restore")}</Button> <Button onClick={confirmRestore}>{t("Restore")}</Button>
</Group> </Group>
)} )}
@@ -7,29 +7,21 @@ import {
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
interface Props { function HistoryView() {
historyId?: string;
prevHistoryId?: string;
}
function HistoryView({ historyId, prevHistoryId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const activeId = useAtomValue(activeHistoryIdAtom); const historyId = useAtomValue(activeHistoryIdAtom);
const activePrevId = useAtomValue(activeHistoryPrevIdAtom); const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
const resolvedId = historyId ?? activeId;
const resolvedPrevId = prevHistoryId ?? activePrevId;
const { const {
data, data,
isLoading: isLoadingCurrent, isLoading: isLoadingCurrent,
isError: isErrorCurrent, isError: isErrorCurrent,
} = usePageHistoryQuery(resolvedId); } = usePageHistoryQuery(historyId);
const { const {
data: prevData, data: prevData,
isLoading: isLoadingPrev, isLoading: isLoadingPrev,
isError: isErrorPrev, isError: isErrorPrev,
} = usePageHistoryQuery(resolvedPrevId); } = usePageHistoryQuery(prevHistoryId);
if (isLoadingCurrent || isLoadingPrev) { if (isLoadingCurrent || isLoadingPrev) {
return <></>; return <></>;
@@ -3,45 +3,22 @@ import { useEffect } from "react";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
diffCountsAtom, diffCountsAtom,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
/** /**
* Resets history state when pageId changes. * Resets history state when pageId changes.
* Clears active selection, diff counts, and compare state. * Clears active selection and diff counts.
* Compare state also resets on unmount so reopening the modal starts clean.
*/ */
export function useHistoryReset(pageId: string) { export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom); const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom); const [, setDiffCounts] = useAtom(diffCountsAtom);
const [, setCompareMode] = useAtom(compareModeAtom);
const [, setCompareSelection] = useAtom(compareSelectionAtom);
const [, setComparePair] = useAtom(comparePairAtom);
useEffect(() => { useEffect(() => {
const resetCompare = () => {
setCompareMode(false);
setCompareSelection([]);
setComparePair(null);
};
setActiveHistoryId(""); setActiveHistoryId("");
setActiveHistoryPrevId(""); setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null); setDiffCounts(null);
resetCompare(); }, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
return resetCompare;
}, [
pageId,
setActiveHistoryId,
setActiveHistoryPrevId,
setDiffCounts,
setCompareMode,
setCompareSelection,
setComparePair,
]);
} }
@@ -1,4 +1,4 @@
import { useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
@@ -9,8 +9,7 @@ import {
activeHistoryIdAtom, activeHistoryIdAtom,
historyAtoms, historyAtoms,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import { fetchPageHistory } from "@/features/page-history/queries/page-history-query"; import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { import {
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
@@ -26,6 +25,8 @@ export function useHistoryRestore() {
const { t } = useTranslation(); const { t } = useTranslation();
const activeHistoryId = useAtomValue(activeHistoryIdAtom); const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const mainEditor = useAtomValue(pageEditorAtom); const mainEditor = useAtomValue(pageEditorAtom);
const mainEditorTitle = useAtomValue(titleEditorAtom); const mainEditorTitle = useAtomValue(titleEditorAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms); const setHistoryModalOpen = useSetAtom(historyAtoms);
@@ -39,66 +40,47 @@ export function useHistoryRestore() {
SpaceCaslSubject.Page, SpaceCaslSubject.Page,
); );
const handleRestore = useCallback( const handleRestore = useCallback(() => {
async (historyId: string) => { if (!activeHistoryData) return;
let historyData: IPageHistory; if (
try { !mainEditor ||
historyData = await fetchPageHistory(historyId); mainEditor.isDestroyed ||
} catch { !mainEditorTitle ||
notifications.show({ mainEditorTitle.isDestroyed
message: t("Error fetching page data."), ) {
color: "red", return;
}); }
return;
}
if ( mainEditorTitle
!mainEditor || .chain()
mainEditor.isDestroyed || .clearContent()
!mainEditorTitle || .setContent(activeHistoryData.title, { emitUpdate: true })
mainEditorTitle.isDestroyed .run();
) {
return;
}
mainEditorTitle mainEditor
.chain() .chain()
.clearContent() .clearContent()
.setContent(historyData.title, { emitUpdate: true }) .setContent(activeHistoryData.content)
.run(); .run();
mainEditor setHistoryModalOpen(false);
.chain() notifications.show({ message: t("Successfully restored") });
.clearContent() }, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
.setContent(historyData.content)
.run();
setHistoryModalOpen(false); const confirmRestore = useCallback(() => {
notifications.show({ message: t("Successfully restored") }); modals.openConfirmModal({
}, title: t("Please confirm your action"),
[mainEditor, mainEditorTitle, setHistoryModalOpen, t], children: (
); <Text size="sm">
{t(
const confirmRestore = useCallback( "Are you sure you want to restore this version? Any changes not versioned will be lost.",
(historyId?: string) => { )}
const targetId = historyId ?? activeHistoryId; </Text>
if (!targetId) return; ),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
modals.openConfirmModal({ onConfirm: handleRestore,
title: t("Please confirm your action"), });
children: ( }, [t, handleRestore]);
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: () => handleRestore(targetId),
});
},
[t, handleRestore, activeHistoryId],
);
return { canRestore, confirmRestore }; return { canRestore, confirmRestore };
} }
@@ -23,14 +23,6 @@ export function prefetchPageHistory(historyId: string) {
}); });
} }
export function fetchPageHistory(historyId: string): Promise<IPageHistory> {
return queryClient.fetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery( export function usePageHistoryListQuery(
pageId: string, pageId: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> { ): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveComparePair } from "./resolve-compare-pair";
// list is newest-first, matching usePageHistoryListQuery order
const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }];
describe("resolveComparePair", () => {
it("orders newer before older regardless of selection order", () => {
expect(resolveComparePair(items, ["v1", "v3"])).toEqual({
newerId: "v3",
olderId: "v1",
});
expect(resolveComparePair(items, ["v3", "v1"])).toEqual({
newerId: "v3",
olderId: "v1",
});
});
it("returns null unless exactly two versions are selected", () => {
expect(resolveComparePair(items, [])).toBeNull();
expect(resolveComparePair(items, ["v1"])).toBeNull();
expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull();
});
it("returns null when a selected id is not in the list", () => {
expect(resolveComparePair(items, ["v1", "missing"])).toBeNull();
});
it("returns null when the same id is selected twice", () => {
expect(resolveComparePair(items, ["v2", "v2"])).toBeNull();
});
});
@@ -1,18 +0,0 @@
import { ComparePair } from "@/features/page-history/atoms/history-atoms";
/**
* Resolves which of the two selected versions is newer using their position
* in the history list (list is newest-first: lower index = newer).
*/
export function resolveComparePair(
historyItems: { id: string }[],
selection: string[],
): ComparePair | null {
if (selection.length !== 2) return null;
const indexA = historyItems.findIndex((item) => item.id === selection[0]);
const indexB = historyItems.findIndex((item) => item.id === selection[1]);
if (indexA === -1 || indexB === -1 || indexA === indexB) return null;
return indexA < indexB
? { newerId: selection[0], olderId: selection[1] }
: { newerId: selection[1], olderId: selection[0] };
}
@@ -11,7 +11,6 @@ import {
IconList, IconList,
IconMarkdown, IconMarkdown,
IconMessage, IconMessage,
IconPaperclip,
IconPrinter, IconPrinter,
IconStar, IconStar,
IconStarFilled, IconStarFilled,
@@ -43,7 +42,6 @@ import {
import { formattedDate } from "@/lib/time.ts"; import { formattedDate } from "@/lib/time.ts";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx"; import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission"; import { PageShareModal } from "@/ee/page-permission";
import { import {
@@ -159,10 +157,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
verificationOpened, verificationOpened,
{ open: openVerificationModal, close: closeVerificationModal }, { open: openVerificationModal, close: closeVerificationModal },
] = useDisclosure(false); ] = useDisclosure(false);
const [
attachmentsOpened,
{ open: openAttachmentsModal, close: closeAttachmentsModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom); const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt); const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page", page?.spaceId); const favoriteIds = useFavoriteIds("page", page?.spaceId);
@@ -299,15 +293,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Item> </Menu.Item>
)} )}
{!page?.isBase && (
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={openAttachmentsModal}
>
{t("Attachments")}
</Menu.Item>
)}
{!readOnly && !page?.isBase && ( {!readOnly && !page?.isBase && (
<PageVerificationMenuItem <PageVerificationMenuItem
pageId={page?.id} pageId={page?.id}
@@ -410,12 +395,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
opened={verificationOpened} opened={verificationOpened}
onClose={closeVerificationModal} onClose={closeVerificationModal}
/> />
<PageAttachmentsModal
pageId={page.id}
open={attachmentsOpened}
onClose={closeAttachmentsModal}
/>
</> </>
); );
} }
@@ -13,16 +13,11 @@ import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx"; import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { hintVectorCache } from "@/ee/ai/services/ai-search-service.ts";
import { getAiVectorDriver } from "@/lib/config.ts";
interface SearchSpotlightProps { interface SearchSpotlightProps {
spaceId?: string; spaceId?: string;
} }
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const workspace = useAtomValue(workspaceAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const hasAiFeature = useHasFeature(Feature.AI); const hasAiFeature = useHasFeature(Feature.AI);
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
@@ -101,15 +96,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
/> />
)); ));
const handleSpotlightOpen = () => {
if (
workspace?.settings?.ai?.search === true &&
getAiVectorDriver() === "turbopuffer"
) {
hintVectorCache();
}
};
const handleFiltersChange = (newFilters: any) => { const handleFiltersChange = (newFilters: any) => {
setFilters(newFilters); setFilters(newFilters);
}; };
@@ -129,7 +115,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
<Spotlight.Root <Spotlight.Root
size="xl" size="xl"
maxHeight={600} maxHeight={600}
onSpotlightOpen={handleSpotlightOpen}
store={searchSpotlightStore} store={searchSpotlightStore}
query={query} query={query}
onQueryChange={setQuery} onQueryChange={setQuery}
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { Group, Select, SelectProps, Text } from "@mantine/core"; import { Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -14,7 +14,6 @@ interface SpaceSelectProps {
width?: number; width?: number;
opened?: boolean; opened?: boolean;
clearable?: boolean; clearable?: boolean;
withinPortal?: boolean;
} }
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => ( const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
@@ -42,7 +41,6 @@ export function SpaceSelect({
width, width,
opened, opened,
clearable, clearable,
withinPortal = true,
}: SpaceSelectProps) { }: SpaceSelectProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
@@ -52,13 +50,9 @@ export function SpaceSelect({
limit: 50, limit: 50,
}); });
const [data, setData] = useState([]); const [data, setData] = useState([]);
const fetchedSpaces = useRef(new Map<string, ISpace>());
useEffect(() => { useEffect(() => {
if (spaces) { if (spaces) {
spaces.items.forEach((space: ISpace) =>
fetchedSpaces.current.set(space.slug, space),
);
const spaceData = spaces?.items const spaceData = spaces?.items
.filter((space: ISpace) => space.slug !== value) .filter((space: ISpace) => space.slug !== value)
.map((space: ISpace) => { .map((space: ISpace) => {
@@ -89,19 +83,14 @@ export function SpaceSelect({
onSearchChange={setSearchValue} onSearchChange={setSearchValue}
clearable={clearable} clearable={clearable}
variant="filled" variant="filled"
onChange={(slug) => { onChange={(slug) =>
// options accumulate across fetches; resolve against everything onChange(spaces.items?.find((item) => item.slug === slug))
// fetched, not just the latest query result }
const space = slug && fetchedSpaces.current.get(slug);
if (space) {
onChange(space);
}
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")} nothingFoundMessage={t("No space found")}
limit={50} limit={50}
checkIconPosition="right" checkIconPosition="right"
comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }} comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
dropdownOpened={opened} dropdownOpened={opened}
/> />
); );
@@ -70,7 +70,6 @@ export function SwitchSpace({
onChange={(space) => handleSelect(space.slug)} onChange={(space) => handleSelect(space.slug)}
width={300} width={300}
opened={true} opened={true}
withinPortal={false}
/> />
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
-4
View File
@@ -43,10 +43,6 @@ export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD")); return castToBoolean(getConfigValue("CLOUD"));
} }
export function getAiVectorDriver(): string {
return getConfigValue("AI_VECTOR_DRIVER");
}
export function getAvatarUrl( export function getAvatarUrl(
avatarUrl: string, avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR, type: AvatarIconType = AvatarIconType.AVATAR,
@@ -1,10 +1,13 @@
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form"; import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { DocumentTitle } from "@/components/ui/document-title.tsx"; import { getAppName } from "@/lib/config";
import { Helmet } from "react-helmet-async";
export default function ForgotPassword() { export default function ForgotPassword() {
return ( return (
<> <>
<DocumentTitle title="Forgot Password" /> <Helmet>
<title>Forgot Password - {getAppName()}</title>
</Helmet>
<ForgotPasswordForm /> <ForgotPasswordForm />
</> </>
); );
+5 -2
View File
@@ -1,13 +1,16 @@
import { Helmet } from "react-helmet-async";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx"; import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
import {getAppName} from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function InviteSignup() { export default function InviteSignup() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title={t("Invitation Signup")} /> <Helmet>
<title>{t("Invitation Signup")} - {getAppName()}</title>
</Helmet>
<InviteSignUpForm /> <InviteSignUpForm />
</> </>
); );
+7 -2
View File
@@ -1,13 +1,18 @@
import { LoginForm } from "@/features/auth/components/login-form"; import { LoginForm } from "@/features/auth/components/login-form";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function LoginPage() { export default function LoginPage() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title={t("Login")} /> <Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<LoginForm /> <LoginForm />
</> </>
); );
+12 -3
View File
@@ -1,10 +1,11 @@
import { Helmet } from "react-helmet-async";
import { PasswordResetForm } from "@/features/auth/components/password-reset-form"; import { PasswordResetForm } from "@/features/auth/components/password-reset-form";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query"; import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query";
import { Button, Container, Group, Text } from "@mantine/core"; import { Button, Container, Group, Text } from "@mantine/core";
import APP_ROUTE from "@/lib/app-route"; import APP_ROUTE from "@/lib/app-route";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function PasswordReset() { export default function PasswordReset() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -22,7 +23,11 @@ export default function PasswordReset() {
if (isError || !resetToken) { if (isError || !resetToken) {
return ( return (
<> <>
<DocumentTitle title={t("Password Reset")} /> <Helmet>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<Container my={40}> <Container my={40}>
<Text size="lg" ta="center"> <Text size="lg" ta="center">
{t("Invalid or expired password reset link")} {t("Invalid or expired password reset link")}
@@ -44,7 +49,11 @@ export default function PasswordReset() {
return ( return (
<> <>
<DocumentTitle title={t("Password Reset")} /> <Helmet>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<PasswordResetForm resetToken={resetToken} /> <PasswordResetForm resetToken={resetToken} />
</> </>
); );
@@ -1,10 +1,11 @@
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts"; import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx"; import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts"; import APP_ROUTE from "@/lib/app-route.ts";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function SetupWorkspace() { export default function SetupWorkspace() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -34,7 +35,11 @@ export default function SetupWorkspace() {
) { ) {
return ( return (
<> <>
<DocumentTitle title={t("Setup Workspace")} /> <Helmet>
<title>
{t("Setup Workspace")} - {getAppName()}
</title>
</Helmet>
<SetupWorkspaceForm /> <SetupWorkspaceForm />
</> </>
); );
+7 -2
View File
@@ -2,15 +2,20 @@ import { Container, Space } from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs"; import HomeTabs from "@/features/home/components/home-tabs";
import HomeAiPrompt from "@/features/home/components/home-ai-prompt"; import HomeAiPrompt from "@/features/home/components/home-ai-prompt";
import SpaceCarousel from "@/features/space/components/space-carousel.tsx"; import SpaceCarousel from "@/features/space/components/space-carousel.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Home() { export default function Home() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title={t("Home")} /> <Helmet>
<title>
{t("Home")} - {getAppName()}
</title>
</Helmet>
<Container size={"900"} pt="xl"> <Container size={"900"} pt="xl">
<HomeAiPrompt /> <HomeAiPrompt />
+7 -2
View File
@@ -17,7 +17,9 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import { useLabelPagesQuery } from "@/features/label/queries/label-query.ts"; import { useLabelPagesQuery } from "@/features/label/queries/label-query.ts";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts"; import { getLabelColor } from "@/features/label/utils/label-colors.ts";
@@ -27,7 +29,6 @@ import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu.tsx"; import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu.tsx";
import { EmptyState } from "@/components/ui/empty-state"; import { EmptyState } from "@/components/ui/empty-state";
import classes from "@/features/label/label.module.css"; import classes from "@/features/label/label.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function LabelPage() { export default function LabelPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -81,7 +82,11 @@ export default function LabelPage() {
return ( return (
<> <>
<DocumentTitle title={labelName} /> <Helmet>
<title>
{labelName} - {getAppName()}
</title>
</Helmet>
<Container size={820} py="xl"> <Container size={820} py="xl">
<Stack gap="lg"> <Stack gap="lg">
+7 -9
View File
@@ -3,6 +3,7 @@ import { usePageQuery } from "@/features/page/queries/page-query";
import { FullEditor } from "@/features/editor/full-editor"; import { FullEditor } from "@/features/editor/full-editor";
import { TitleEditor } from "@/features/editor/title-editor"; import { TitleEditor } from "@/features/editor/title-editor";
import HistoryModal from "@/features/page-history/components/history-modal"; import HistoryModal from "@/features/page-history/components/history-modal";
import { Helmet } from "react-helmet-async";
import PageHeader from "@/features/page/components/header/page-header.tsx"; import PageHeader from "@/features/page/components/header/page-header.tsx";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
@@ -17,7 +18,6 @@ import { BaseView } from "@/ee/base/components/base-view";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import { getPageTitle } from "@/features/page/page.utils"; import { getPageTitle } from "@/features/page/page.utils";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const MemoizedFullEditor = React.memo(FullEditor); const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageHeader = React.memo(PageHeader); const MemoizedPageHeader = React.memo(PageHeader);
@@ -110,10 +110,9 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
paddingTop: "calc(var(--page-header-height) + 6px)", paddingTop: "calc(var(--page-header-height) + 6px)",
}} }}
> >
<DocumentTitle <Helmet>
title={`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`} <title>{`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}</title>
withAppName={false} </Helmet>
/>
<MemoizedPageHeader readOnly={!canEdit} /> <MemoizedPageHeader readOnly={!canEdit} />
<div <div
style={{ style={{
@@ -160,10 +159,9 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
return ( return (
page && ( page && (
<div> <div>
<DocumentTitle <Helmet>
title={`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`} <title>{`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}</title>
withAppName={false} </Helmet>
/>
<MemoizedPageHeader readOnly={!canEdit} /> <MemoizedPageHeader readOnly={!canEdit} />
@@ -5,16 +5,21 @@ import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref"; import PageEditPref from "@/features/user/components/page-state-pref";
import FixedToolbarPref from "@/features/user/components/fixed-toolbar-pref"; import FixedToolbarPref from "@/features/user/components/fixed-toolbar-pref";
import NotificationPref from "@/features/user/components/notification-pref"; import NotificationPref from "@/features/user/components/notification-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core"; import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AccountPreferences() { export default function AccountPreferences() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title={t("Preferences")} /> <Helmet>
<title>
{t("Preferences")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Preferences")} /> <SettingsTitle title={t("Preferences")} />
<AccountTheme /> <AccountTheme />
@@ -4,17 +4,22 @@ import ChangePassword from "@/features/user/components/change-password";
import { Divider } from "@mantine/core"; import { Divider } from "@mantine/core";
import AccountAvatar from "@/features/user/components/account-avatar"; import AccountAvatar from "@/features/user/components/account-avatar";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AccountMfaSection } from "@/features/user/components/account-mfa-section"; import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
import SessionList from "@/features/session/components/session-list"; import SessionList from "@/features/session/components/session-list";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AccountSettings() { export default function AccountSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title={t("My Profile")} /> <Helmet>
<title>
{t("My Profile")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("My Profile")} /> <SettingsTitle title={t("My Profile")} />
<AccountAvatar /> <AccountAvatar />
@@ -1,15 +1,20 @@
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import GroupMembersList from "@/features/group/components/group-members"; import GroupMembersList from "@/features/group/components/group-members";
import GroupDetails from "@/features/group/components/group-details"; import GroupDetails from "@/features/group/components/group-details";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function GroupInfo() { export default function GroupInfo() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title={t("Manage Group")} /> <Helmet>
<title>
{t("Manage Group")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Manage Group")} /> <SettingsTitle title={t("Manage Group")} />
<GroupDetails /> <GroupDetails />
<GroupMembersList /> <GroupMembersList />
@@ -3,8 +3,9 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Group } from "@mantine/core"; import { Group } from "@mantine/core";
import CreateGroupModal from "@/features/group/components/create-group-modal"; import CreateGroupModal from "@/features/group/components/create-group-modal";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Groups() { export default function Groups() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -12,7 +13,9 @@ export default function Groups() {
return ( return (
<> <>
<DocumentTitle title={t("Groups")} /> <Helmet>
<title>{t("Groups")} - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("Groups")} /> <SettingsTitle title={t("Groups")} />
<Group my="md" justify="flex-end"> <Group my="md" justify="flex-end">
@@ -1,17 +1,22 @@
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import ShareList from "@/features/share/components/share-list.tsx"; import ShareList from "@/features/share/components/share-list.tsx";
import { Alert, Text } from "@mantine/core"; import { Alert, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import React from "react"; import React from "react";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Shares() { export default function Shares() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title={t("Public sharing")} /> <Helmet>
<title>
{t("Public sharing")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Public sharing")} /> <SettingsTitle title={t("Public sharing")} />
<Alert variant="light" color="blue" icon={<IconInfoCircle />}> <Alert variant="light" color="blue" icon={<IconInfoCircle />}>
@@ -3,8 +3,9 @@ import SpaceList from "@/features/space/components/space-list.tsx";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { Group } from "@mantine/core"; import { Group } from "@mantine/core";
import CreateSpaceModal from "@/features/space/components/create-space-modal.tsx"; import CreateSpaceModal from "@/features/space/components/create-space-modal.tsx";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Spaces() { export default function Spaces() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -12,7 +13,11 @@ export default function Spaces() {
return ( return (
<> <>
<DocumentTitle title={t("Spaces")} /> <Helmet>
<title>
{t("Spaces")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Spaces")} /> <SettingsTitle title={t("Spaces")} />
<Group my="md" justify="flex-end"> <Group my="md" justify="flex-end">
@@ -6,10 +6,11 @@ import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx"; import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceMembers() { export default function WorkspaceMembers() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -37,7 +38,11 @@ export default function WorkspaceMembers() {
return ( return (
<> <>
<DocumentTitle title={t("Members")} /> <Helmet>
<title>
{t("Members")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Members")} /> <SettingsTitle title={t("Members")} />
{/* <WorkspaceInviteSection /> */} {/* <WorkspaceInviteSection /> */}
@@ -2,19 +2,21 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form"; import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx"; import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { getAppName, isCloud } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import ManageHostname from "@/ee/components/manage-hostname.tsx"; import ManageHostname from "@/ee/components/manage-hostname.tsx";
import { Divider } from "@mantine/core"; import { Divider } from "@mantine/core";
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx"; import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
import WorkspaceDefaultPageEditMode from "@/features/workspace/components/settings/components/workspace-default-page-edit-mode.tsx"; import WorkspaceDefaultPageEditMode from "@/features/workspace/components/settings/components/workspace-default-page-edit-mode.tsx";
import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx"; import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceSettings() { export default function WorkspaceSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title="Workspace Settings" /> <Helmet>
<title>Workspace Settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("General")} /> <SettingsTitle title={t("General")} />
<WorkspaceIcon /> <WorkspaceIcon />
<WorkspaceNameForm /> <WorkspaceNameForm />
+4 -6
View File
@@ -1,4 +1,5 @@
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { Container } from "@mantine/core"; import { Container } from "@mantine/core";
@@ -13,7 +14,6 @@ import {
sharedTreeDataAtom, sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom.ts"; } from "@/features/share/atoms/shared-page-atom.ts";
import { isPageInTree } from "@/features/share/utils.ts"; import { isPageInTree } from "@/features/share/utils.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function SharedPage() { export default function SharedPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -56,14 +56,12 @@ export default function SharedPage() {
return ( return (
<div> <div>
<DocumentTitle <Helmet>
title={data?.page?.title || t("untitled")} <title>{`${data?.page?.title || t("untitled")}`}</title>
withAppName={false}
>
{!data?.share.searchIndexing && ( {!data?.share.searchIndexing && (
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
)} )}
</DocumentTitle> </Helmet>
<Container fluid={fullWidth} size={fullWidth ? undefined : 900} p={0}> <Container fluid={fullWidth} size={fullWidth ? undefined : 900} p={0}>
<ReadonlyPageEditor <ReadonlyPageEditor
+5 -2
View File
@@ -2,7 +2,8 @@ import {Container} from "@mantine/core";
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx"; import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
import {useParams} from "react-router-dom"; import {useParams} from "react-router-dom";
import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts"; import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx"; import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function SpaceHome() { export default function SpaceHome() {
const {spaceSlug} = useParams(); const {spaceSlug} = useParams();
@@ -10,7 +11,9 @@ export default function SpaceHome() {
return ( return (
<> <>
<DocumentTitle title={space?.name || 'Overview'} /> <Helmet>
<title>{space?.name || 'Overview'} - {getAppName()}</title>
</Helmet>
<Container size={"900"} pt="xl"> <Container size={"900"} pt="xl">
{space && <SpaceHomeTabs/>} {space && <SpaceHomeTabs/>}
</Container> </Container>
+7 -2
View File
@@ -1,12 +1,13 @@
import { Container, Title, Text, Group, Box } from "@mantine/core"; import { Container, Title, Text, Group, Box } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import CreateSpaceModal from "@/features/space/components/create-space-modal"; import CreateSpaceModal from "@/features/space/components/create-space-modal";
import { AllSpacesList } from "@/features/space/components/spaces-page"; import { AllSpacesList } from "@/features/space/components/spaces-page";
import FavoriteSpacesGrid from "@/features/space/components/spaces-page/favorite-spaces-grid"; import FavoriteSpacesGrid from "@/features/space/components/spaces-page/favorite-spaces-grid";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search"; import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import useUserRole from "@/hooks/use-user-role"; import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Spaces() { export default function Spaces() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -21,7 +22,11 @@ export default function Spaces() {
return ( return (
<> <>
<DocumentTitle title={t("Spaces")} /> <Helmet>
<title>
{t("Spaces")} - {getAppName()}
</title>
</Helmet>
<Container size={"800"} pt="xl"> <Container size={"800"} pt="xl">
<Group justify="space-between" mb="xl"> <Group justify="space-between" mb="xl">
-2
View File
@@ -16,7 +16,6 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS, BILLING_TRIAL_DAYS,
POSTHOG_HOST, POSTHOG_HOST,
POSTHOG_KEY, POSTHOG_KEY,
AI_VECTOR_DRIVER,
} = loadEnv(mode, envPath, ""); } = loadEnv(mode, envPath, "");
return { return {
@@ -32,7 +31,6 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS, BILLING_TRIAL_DAYS,
POSTHOG_HOST, POSTHOG_HOST,
POSTHOG_KEY, POSTHOG_KEY,
AI_VECTOR_DRIVER,
}, },
APP_VERSION: JSON.stringify(process.env.npm_package_version), APP_VERSION: JSON.stringify(process.env.npm_package_version),
}, },
+19 -18
View File
@@ -40,33 +40,32 @@
"@clickhouse/client": "1.18.2", "@clickhouse/client": "1.18.2",
"@docmost/base-formula": "workspace:*", "@docmost/base-formula": "workspace:*",
"@docmost/pdf-inspector": "1.9.6", "@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "11.1.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "10.1.1", "@fastify/multipart": "^10.0.0",
"@fastify/static": "10.1.3", "@fastify/static": "^9.1.3",
"@keyv/redis": "5.1.6", "@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.46", "@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1", "@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "1.30.0", "@modelcontextprotocol/sdk": "1.29.0",
"@nest-lab/throttler-storage-redis": "1.2.0", "@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs-labs/nestjs-ioredis": "11.0.4", "@nestjs-labs/nestjs-ioredis": "11.0.4",
"@nestjs/bullmq": "11.0.5", "@nestjs/bullmq": "11.0.4",
"@nestjs/cache-manager": "3.1.3", "@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.2.1", "@nestjs/common": "11.1.27",
"@nestjs/config": "4.0.4", "@nestjs/config": "4.0.4",
"@nestjs/core": "11.2.1", "@nestjs/core": "11.1.27",
"@nestjs/event-emitter": "3.1.0", "@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2", "@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "2.1.1", "@nestjs/mapped-types": "2.1.1",
"@nestjs/passport": "11.0.5", "@nestjs/passport": "11.0.5",
"@nestjs/platform-fastify": "11.2.1", "@nestjs/platform-fastify": "11.1.27",
"@nestjs/platform-socket.io": "11.2.1", "@nestjs/platform-socket.io": "11.1.27",
"@nestjs/schedule": "6.1.3", "@nestjs/schedule": "6.1.3",
"@nestjs/terminus": "11.1.1", "@nestjs/terminus": "11.1.1",
"@nestjs/throttler": "6.5.0", "@nestjs/throttler": "6.5.0",
"@nestjs/websockets": "11.2.1", "@nestjs/websockets": "11.1.27",
"@node-saml/passport-saml": "5.1.0", "@node-saml/passport-saml": "5.1.0",
"@socket.io/redis-adapter": "8.3.0", "@socket.io/redis-adapter": "8.3.0",
"@turbopuffer/turbopuffer": "^2.8.0",
"ai": "6.0.134", "ai": "6.0.134",
"ai-sdk-ollama": "3.8.1", "ai-sdk-ollama": "3.8.1",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
@@ -89,10 +88,11 @@
"kysely-migration-cli": "0.4.2", "kysely-migration-cli": "0.4.2",
"kysely-postgres-js": "3.0.0", "kysely-postgres-js": "3.0.0",
"ldapts": "8.1.7", "ldapts": "8.1.7",
"lib0": "0.2.117",
"mammoth": "1.12.0", "mammoth": "1.12.0",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"msgpackr": "1.11.9", "msgpackr": "^1.11.9",
"nanoid": "5.1.16", "nanoid": "5.1.7",
"nestjs-cls": "6.2.0", "nestjs-cls": "6.2.0",
"nestjs-kysely": "3.1.2", "nestjs-kysely": "3.1.2",
"nestjs-pino": "4.6.1", "nestjs-pino": "4.6.1",
@@ -103,7 +103,7 @@
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"pg-tsquery": "8.4.2", "pg-tsquery": "8.4.2",
"pgvector": "0.2.1", "pgvector": "^0.2.1",
"pino-http": "11.0.0", "pino-http": "11.0.0",
"pino-pretty": "13.1.3", "pino-pretty": "13.1.3",
"postgres": "3.4.8", "postgres": "3.4.8",
@@ -118,9 +118,10 @@
"stripe": "^17.7.0", "stripe": "^17.7.0",
"tlds": "1.261.0", "tlds": "1.261.0",
"tmp-promise": "3.0.3", "tmp-promise": "3.0.3",
"tseep": "1.3.1",
"typesense": "3.0.5", "typesense": "3.0.5",
"undici": "7.29.0", "undici": "7.28.0",
"ws": "8.21.3", "ws": "8.21.0",
"yauzl": "3.4.0", "yauzl": "3.4.0",
"zod": "4.3.6" "zod": "4.3.6"
}, },
+2 -15
View File
@@ -22,13 +22,11 @@ import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis'; import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service'; import { RedisConfigService } from './integrations/redis/redis-config.service';
import { CacheModule } from '@nestjs/cache-manager'; import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis, { defaultReconnectStrategy } from '@keyv/redis'; import KeyvRedis from '@keyv/redis';
import { parseRedisUrl } from './common/helpers';
import { LoggerModule } from './common/logger/logger.module'; import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module'; import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module'; import { ThrottleModule } from './integrations/throttle/throttle.module';
import { EncryptionModule } from './integrations/encryption/encryption.module';
const enterpriseModules = []; const enterpriseModules = [];
try { try {
@@ -55,7 +53,6 @@ try {
CoreModule, CoreModule,
DatabaseModule, DatabaseModule,
EnvironmentModule, EnvironmentModule,
EncryptionModule,
RedisModule.forRootAsync({ RedisModule.forRootAsync({
useClass: RedisConfigService, useClass: RedisConfigService,
}), }),
@@ -63,20 +60,10 @@ try {
isGlobal: true, isGlobal: true,
useFactory: async (environmentService: EnvironmentService) => { useFactory: async (environmentService: EnvironmentService) => {
const redisUrl = environmentService.getRedisUrl(); const redisUrl = environmentService.getRedisUrl();
const { family, tls } = parseRedisUrl(redisUrl);
return { return {
ttl: 5 * 1000, ttl: 5 * 1000,
stores: [ stores: [new KeyvRedis(redisUrl)],
new KeyvRedis({
url: redisUrl,
socket: {
family,
reconnectStrategy: defaultReconnectStrategy,
...tls,
},
}),
],
}; };
}, },
inject: [EnvironmentService], inject: [EnvironmentService],
@@ -15,7 +15,6 @@ import {
RedisSyncExtension, RedisSyncExtension,
SerializedHTTPRequest, SerializedHTTPRequest,
} from './extensions/redis-sync'; } from './extensions/redis-sync';
import { toWebRequest } from './extensions/redis-sync/redis-sync.types';
import { WsSocketWrapper } from './extensions/redis-sync/ws-socket-wrapper'; import { WsSocketWrapper } from './extensions/redis-sync/ws-socket-wrapper';
import RedisClient from 'ioredis'; import RedisClient from 'ioredis';
import { pack, unpack } from 'msgpackr'; import { pack, unpack } from 'msgpackr';
@@ -66,7 +65,6 @@ export class CollaborationGateway {
password: this.redisConfig.password, password: this.redisConfig.password,
db: this.redisConfig.db, db: this.redisConfig.db,
family: this.redisConfig.family, family: this.redisConfig.family,
tls: this.redisConfig.tls,
retryStrategy: createRetryStrategy(), retryStrategy: createRetryStrategy(),
}), }),
serverId: `collab-${os?.hostname()}-${nanoid(10)}`, serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
@@ -100,36 +98,34 @@ export class CollaborationGateway {
const serializedHTTPRequest = this.serializeRequest(request); const serializedHTTPRequest = this.serializeRequest(request);
const socketId = serializedHTTPRequest.headers['sec-websocket-key']; 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); const wrappedSocket = new WsSocketWrapper(client);
// Route through RedisSync extension (this calls handleConnection internally) // Route through RedisSync extension (this calls handleConnection internally)
this.redisSync.onSocketOpen(wrappedSocket, serializedHTTPRequest); this.redisSync.onSocketOpen(wrappedSocket as any, serializedHTTPRequest);
// Forward raw WebSocket messages to the extension
client.on('message', (data: ArrayBuffer) => { client.on('message', (data: ArrayBuffer) => {
this.redisSync!.onSocketMessage(serializedHTTPRequest, data); this.redisSync!.onSocketMessage(
wrappedSocket as any,
serializedHTTPRequest,
data,
);
}); });
// Forward close events
client.on('close', (code: number, reason: Buffer) => { client.on('close', (code: number, reason: Buffer) => {
this.redisSync!.onSocketClose( this.redisSync!.onSocketClose(socketId, code, reason.buffer as ArrayBuffer);
socketId, });
code,
new Uint8Array(reason).buffer, // Forward pong events for keepalive
); client.on('pong', (data: Buffer) => {
wrappedSocket.emit('pong', data);
}); });
} else { } else {
// Fallback to direct Hocuspocus connection // Fallback to direct Hocuspocus connection
const clientConnection = this.hocuspocus.handleConnection( this.hocuspocus.handleConnection(client, request);
client,
toWebRequest(this.serializeRequest(request)),
);
client.on('message', (data: Buffer) => {
clientConnection.handleMessage(new Uint8Array(data));
});
client.on('close', (code: number, reason: Buffer) => {
clientConnection.handleClose({ code, reason: reason.toString() });
});
} }
} }
@@ -182,7 +178,6 @@ export class CollaborationGateway {
if (this.hocuspocus.getDocumentsCount() === 0) resolve(''); if (this.hocuspocus.getDocumentsCount() === 0) resolve('');
this.hocuspocus.closeConnections(); this.hocuspocus.closeConnections();
this.hocuspocus.flushPendingStores();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@@ -1,5 +1,4 @@
import { StarterKit } from '@tiptap/starter-kit'; import { StarterKit } from '@tiptap/starter-kit';
import { Document } from '@tiptap/extension-document';
import { TextAlign } from '@tiptap/extension-text-align'; import { TextAlign } from '@tiptap/extension-text-align';
import { Superscript } from '@tiptap/extension-superscript'; import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
@@ -46,18 +45,9 @@ import {
TransclusionSource, TransclusionSource,
TransclusionReference, TransclusionReference,
BaseEmbed, BaseEmbed,
Footnotes,
Footnote,
FootnoteReference,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { import { generateText, getSchema, JSONContent } from '@tiptap/core';
extensions as coreExtensions,
generateText,
getSchema,
JSONContent,
} from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
import { collapseBlankLines } from '../common/helpers';
// @tiptap/html library works best for generating prosemirror json state but not HTML // @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352 // see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
@@ -67,17 +57,12 @@ import * as Y from 'yjs';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
export const tiptapExtensions = [ export const tiptapExtensions = [
coreExtensions.TextDirection.configure({ direction: 'auto' }),
StarterKit.configure({ StarterKit.configure({
document: false,
codeBlock: false, codeBlock: false,
link: false, link: false,
trailingNode: false, trailingNode: false,
heading: false, heading: false,
}), }),
Document.extend({
content: 'block+ footnotes?',
}),
Heading, Heading,
UniqueID.configure({ UniqueID.configure({
types: ['heading', 'paragraph', 'transclusionSource'], types: ['heading', 'paragraph', 'transclusionSource'],
@@ -125,10 +110,7 @@ export const tiptapExtensions = [
Status, Status,
TransclusionSource, TransclusionSource,
TransclusionReference, TransclusionReference,
BaseEmbed, BaseEmbed
Footnotes,
Footnote,
FootnoteReference,
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {
@@ -147,7 +129,7 @@ export function htmlToJson(html: string) {
} }
export function jsonToText(tiptapJson: JSONContent) { export function jsonToText(tiptapJson: JSONContent) {
return collapseBlankLines(generateText(tiptapJson, tiptapExtensions)); return generateText(tiptapJson, tiptapExtensions);
} }
export function jsonToNode(tiptapJson: JSONContent) { export function jsonToNode(tiptapJson: JSONContent) {
@@ -96,7 +96,7 @@ export class PersistenceExtension implements Extension {
} }
async onStoreDocument(data: onStoreDocumentPayload) { async onStoreDocument(data: onStoreDocumentPayload) {
const { documentName, document, lastContext } = data; const { documentName, document, context } = data;
const pageId = getPageId(documentName); const pageId = getPageId(documentName);
@@ -151,7 +151,7 @@ export class PersistenceExtension implements Extension {
content: tiptapJson, content: tiptapJson,
textContent: textContent, textContent: textContent,
ydoc: ydocState, ydoc: ydocState,
lastUpdatedById: lastContext.user.id, lastUpdatedById: context.user.id,
contributorIds: contributorIds, contributorIds: contributorIds,
}, },
pageId, pageId,
@@ -169,12 +169,12 @@ export class PersistenceExtension implements Extension {
JSON.stringify({ JSON.stringify({
type: 'page.updated', type: 'page.updated',
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
lastUpdatedById: lastContext?.user?.id, lastUpdatedById: context?.user?.id,
lastUpdatedBy: lastContext?.user lastUpdatedBy: context?.user
? { ? {
id: lastContext.user?.id, id: context.user?.id,
name: lastContext.user?.name, name: context.user?.name,
avatarUrl: lastContext.user?.avatarUrl, avatarUrl: context.user?.avatarUrl,
} }
: undefined, : undefined,
}), }),
@@ -1,37 +1,61 @@
import type RedisClient from 'ioredis'; import type RedisClient from 'ioredis';
import type { WebSocketLike } from '@hocuspocus/server'; import { EventEmitter } from 'tseep';
import type { Pack, RSAMessageClose, RSAMessageSend } from './redis-sync.types'; import type {
Pack,
RSAMessageClose,
RSAMessagePing,
RSAMessageSend,
} from './redis-sync.types';
// Stands in for the client WebSocket on the server that owns the document. export class CollabProxySocket extends EventEmitter {
// Outgoing traffic is relayed over redis to the origin server, which holds the real socket.
export class CollabProxySocket implements WebSocketLike {
private readonly replyTo: string; private readonly replyTo: string;
private readonly serverChannel: string;
private readonly socketId: string; private readonly socketId: string;
private pub: RedisClient; private pub: RedisClient;
private readonly pack: Pack; private readonly pack: Pack;
readyState = 1; readyState = 1;
onClose?: (code?: number, reason?: string) => void;
constructor(pub: RedisClient, pack: Pack, replyTo: string, socketId: string) { constructor(
pub: RedisClient,
pack: Pack,
replyTo: string,
serverChannel: string,
socketId: string,
) {
super();
this.replyTo = replyTo; this.replyTo = replyTo;
this.socketId = socketId; this.socketId = socketId;
this.serverChannel = serverChannel;
this.pub = pub; this.pub = pub;
this.pack = pack; this.pack = pack;
this.once('close', () => {
this.readyState = 3;
});
} }
private publish(msg: RSAMessageClose | RSAMessageSend) { private publish(msg: RSAMessageClose | RSAMessagePing | RSAMessageSend) {
this.pub.publish(this.replyTo, this.pack(msg)); this.pub.publish(this.replyTo, this.pack(msg));
} }
// The origin server already closed the real socket; stop relaying without echoing a close back
markClosed() {
this.readyState = 3;
}
close(code?: number, reason?: string) { close(code?: number, reason?: string) {
if (this.readyState !== 1) return; if (this.readyState !== 1) return;
this.readyState = 3; const msg: RSAMessageClose = {
this.onClose?.(code, reason); 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) { send(message: Uint8Array) {
@@ -3,30 +3,27 @@ import {
Extension, Extension,
Hocuspocus, Hocuspocus,
IncomingMessage, IncomingMessage,
afterUnloadDocumentPayload,
onConfigurePayload, onConfigurePayload,
onLoadDocumentPayload, onLoadDocumentPayload,
afterUnloadDocumentPayload,
WebSocketLike,
} from '@hocuspocus/server'; } from '@hocuspocus/server';
import { ConnectionTimeout, Unauthorized } from '@hocuspocus/common';
import RedisClient from 'ioredis'; import RedisClient from 'ioredis';
import { readVarString } from 'lib0/decoding.js';
import { CollabProxySocket } from './collab-proxy-socket'; import { CollabProxySocket } from './collab-proxy-socket';
import { import {
BaseWebSocket,
Configuration, Configuration,
CustomEvents, CustomEvents,
Pack, Pack,
RSAMessage, RSAMessage,
RSAMessageClose,
RSAMessageCloseProxy, RSAMessageCloseProxy,
RSAMessageCustomEventComplete, RSAMessageCustomEventComplete,
RSAMessageCustomEventStart, RSAMessageCustomEventStart,
RSAMessagePong,
RSAMessageProxy, RSAMessageProxy,
RSAMessageUnload, RSAMessageUnload,
SerializedHTTPRequest, SerializedHTTPRequest,
Unpack, Unpack,
OriginConnection,
ProxyConnection,
toWebRequest,
} from './redis-sync.types'; } from './redis-sync.types';
export type { Pack, SerializedHTTPRequest } from './redis-sync.types'; export type { Pack, SerializedHTTPRequest } from './redis-sync.types';
@@ -41,10 +38,10 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
private sub: RedisClient; private sub: RedisClient;
private readonly pack: Pack; private readonly pack: Pack;
private readonly unpack: Unpack; private readonly unpack: Unpack;
private originConnections: Record<SocketId, OriginConnection> = {}; private originSockets: Record<SocketId, BaseWebSocket> = {};
private locks: Record<DocumentName, NodeJS.Timeout> = {}; private locks: Record<DocumentName, NodeJS.Timeout> = {};
private lockPromises: Record<DocumentName, Promise<ServerId | null>> = {}; private lockPromises: Record<DocumentName, Promise<ServerId | null>> = {};
private proxyConnections: Record<SocketId, ProxyConnection> = {}; private proxySockets: Record<SocketId, CollabProxySocket> = {};
private readonly prefix: string; private readonly prefix: string;
private readonly lockPrefix: string; private readonly lockPrefix: string;
private readonly msgChannel: string; private readonly msgChannel: string;
@@ -57,9 +54,6 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
// @ts-ignore // @ts-ignore
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> = private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> =
{}; {};
private deriveContext: (
serializedHTTPRequest: SerializedHTTPRequest,
) => Record<string, any>;
constructor(configuration: Configuration<TCE>) { constructor(configuration: Configuration<TCE>) {
const { const {
@@ -71,7 +65,6 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
prefix, prefix,
customEvents, customEvents,
customEventTTL, customEventTTL,
deriveContext,
} = configuration; } = configuration;
this.pub = redis.duplicate(); this.pub = redis.duplicate();
this.sub = redis.duplicate(); this.sub = redis.duplicate();
@@ -84,7 +77,6 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
this.lockPrefix = `${this.prefix}Lock`; this.lockPrefix = `${this.prefix}Lock`;
this.msgChannel = `${this.prefix}Msg`; this.msgChannel = `${this.prefix}Msg`;
this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents); this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents);
this.deriveContext = deriveContext ?? (() => ({}));
this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`); this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`);
this.sub.on('messageBuffer', this.handleRedisMessage); this.sub.on('messageBuffer', this.handleRedisMessage);
this.pub.on('error', () => {}); this.pub.on('error', () => {});
@@ -95,63 +87,44 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
} }
private closeProxy(socketId: string) { private closeProxy(socketId: string) {
const entry = this.proxyConnections[socketId]; const proxySocket = this.proxySockets[socketId];
if (entry) { if (proxySocket) {
delete this.proxyConnections[socketId]; proxySocket.emit(
const { socket, clientConnection } = entry; 'close',
// The origin socket is already gone; don't echo a close message back 1000,
socket.markClosed(); Buffer.from('provider_initiated', 'utf-8'),
clientConnection.handleClose({ );
code: 1000, delete this.proxySockets[socketId];
reason: 'provider_initiated',
});
} }
} }
private pongProxy(socketId: string) {
this.proxySockets[socketId]?.emit('pong');
}
private handleProxyMessage( private handleProxyMessage(
msg: Pick<RSAMessageProxy, 'replyTo' | 'message' | 'serializedHTTPRequest'>, msg: Pick<RSAMessageProxy, 'replyTo' | 'message' | 'serializedHTTPRequest'>,
) { ) {
const { replyTo, message, serializedHTTPRequest } = msg; const { replyTo, message, serializedHTTPRequest } = msg;
const { headers } = serializedHTTPRequest; const { headers } = serializedHTTPRequest;
const socketId = headers['sec-websocket-key']; const socketId = headers['sec-websocket-key']!;
let entry = this.proxyConnections[socketId]; let socket = this.proxySockets[socketId];
if (!entry) { if (!socket) {
const socket = new CollabProxySocket( socket = new CollabProxySocket(
this.pub, this.pub,
this.pack, this.pack,
replyTo, replyTo,
`${this.msgChannel}:${this.serverId}`,
socketId, socketId,
); );
// A proxy connection with no live documents (client left the page, auth this.proxySockets[socketId] = socket;
// failed, or the origin server crashed) is reaped by hocuspocus' message this.instance.handleConnection(
// timeout. Dispose it silently in that case: relaying the timeout close socket as any,
// to the origin would kill the client's real socket, which may be busy serializedHTTPRequest as any,
// serving other documents. Genuine protocol closes are still relayed. {},
socket.onClose = (code, reason) => {
delete this.proxyConnections[socketId];
if (code !== ConnectionTimeout.code) {
const msg: RSAMessageClose = {
type: 'close',
code,
reason,
socketId,
};
this.pub.publish(replyTo, this.pack(msg));
}
};
const clientConnection = this.instance.handleConnection(
socket,
toWebRequest(serializedHTTPRequest),
this.deriveContext(serializedHTTPRequest),
); );
entry = { clientConnection, socket };
this.proxyConnections[socketId] = entry;
} }
entry.clientConnection.handleMessage(message); socket.emit('message', message);
}
private getLock(documentName: string) {
return this.pub.get(this.getKey(documentName));
} }
private getOrClaimLock(documentName: string) { private getOrClaimLock(documentName: string) {
@@ -193,6 +166,10 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
this.closeProxy(msg.socketId); this.closeProxy(msg.socketId);
return; return;
} }
if (type === 'pong') {
this.pongProxy(msg.socketId);
return;
}
if (type === 'unload') { if (type === 'unload') {
delete this.lockPromises[msg.documentName]; delete this.lockPromises[msg.documentName];
return; return;
@@ -221,14 +198,22 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
return; return;
} }
const { socketId } = msg; const { socketId } = msg;
const entry = this.originConnections[socketId]; const socket = this.originSockets[socketId];
if (!entry) { if (!socket) {
// origin socket already cleaned up // origin socket already cleaned up
return; return;
} }
const { socket } = entry;
if (type === 'close') { if (type === 'close') {
socket.close(msg.code, msg.reason); 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') { } else if (type === 'send') {
socket.send(msg.message); socket.send(msg.message);
} }
@@ -266,8 +251,6 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
eventName: TName, eventName: TName,
documentName: string, documentName: string,
payload: any, payload: any,
// if true, don't claim the lock. Useful for targeting pages that are currently open
onlyIfOpen = false,
) { ) {
const isDocLoadedOnInstance = this.instance.documents.has(documentName); const isDocLoadedOnInstance = this.instance.documents.has(documentName);
@@ -275,14 +258,7 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
return this.handleEventLocally(eventName, documentName, payload); return this.handleEventLocally(eventName, documentName, payload);
} }
const proxyTo = await (onlyIfOpen const proxyTo = await this.getOrClaimLockThrottled(documentName);
? this.getLock(documentName)
: this.getOrClaimLockThrottled(documentName));
if (!proxyTo && onlyIfOpen) {
return;
}
if (proxyTo && proxyTo !== this.serverId) { if (proxyTo && proxyTo !== this.serverId) {
++this.replyIdCounter; // bug in biome thinks this.replyIdCounter is not used if written on the line below ++this.replyIdCounter; // bug in biome thinks this.replyIdCounter is not used if written on the line below
const replyId = this.replyIdCounter; const replyId = this.replyIdCounter;
@@ -301,8 +277,7 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
const { promise, resolve, reject } = Promise.withResolvers(); const { promise, resolve, reject } = Promise.withResolvers();
this.pendingReplies[replyId] = resolve; this.pendingReplies[replyId] = resolve;
setTimeout(() => { setTimeout(() => {
delete this.pendingReplies[replyId]; reject('TIMEOUT');
reject(new Error('TIMEOUT'));
}, this.customEventTTL); }, this.customEventTTL);
return promise as Promise<ReturnType<TCE[TName]>>; return promise as Promise<ReturnType<TCE[TName]>>;
} }
@@ -321,59 +296,36 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
/* WebSocket Server Hooks */ /* WebSocket Server Hooks */
onSocketOpen( onSocketOpen(
ws: WebSocketLike, ws: BaseWebSocket,
serializedHTTPRequest: SerializedHTTPRequest, serializedHTTPRequest: SerializedHTTPRequest,
context = {},
) { ) {
const socketId = serializedHTTPRequest.headers['sec-websocket-key']; const socketId = serializedHTTPRequest.headers['sec-websocket-key']!;
const clientConnection = this.instance.handleConnection( this.originSockets[socketId] = ws;
ws, this.instance.handleConnection(
toWebRequest(serializedHTTPRequest), ws as any,
this.deriveContext(serializedHTTPRequest), serializedHTTPRequest as any,
context,
); );
this.originConnections[socketId] = { clientConnection, socket: ws };
} }
async onSocketMessage( async onSocketMessage(
ws: BaseWebSocket,
serializedHTTPRequest: SerializedHTTPRequest, serializedHTTPRequest: SerializedHTTPRequest,
detachableMsg: ArrayBuffer, detachableMsg: ArrayBuffer,
) { ) {
const socketId = serializedHTTPRequest.headers['sec-websocket-key']; const message = new Uint8Array(detachableMsg.slice());
const entry = this.originConnections[socketId]; const tmpMsg = new IncomingMessage(detachableMsg);
if (!entry) return; const documentName = readVarString(tmpMsg.decoder);
const { clientConnection } = entry;
let message: Uint8Array;
let documentName: string;
try {
message = new Uint8Array(detachableMsg.slice());
const tmpMsg = new IncomingMessage(detachableMsg);
const documentNameAndSessionId = tmpMsg.readVarString();
// session-aware providers suffix the documentName with \0sessionId
const sepIdx = documentNameAndSessionId.indexOf('\0');
documentName =
sepIdx === -1
? documentNameAndSessionId
: documentNameAndSessionId.slice(0, sepIdx);
} catch (error) {
entry.socket.close(Unauthorized.code, Unauthorized.reason);
return;
}
const isDocLoadedOnInstance = this.instance.documents.has(documentName); const isDocLoadedOnInstance = this.instance.documents.has(documentName);
if (isDocLoadedOnInstance) { if (isDocLoadedOnInstance) {
clientConnection.handleMessage(message); ws.emit('message', message);
return; return;
} }
const proxyTo = await this.getOrClaimLockThrottled(documentName); const proxyTo = await this.getOrClaimLockThrottled(documentName);
if (proxyTo && proxyTo !== this.serverId) { if (proxyTo && proxyTo !== this.serverId) {
// Proxied messages bypass handleMessage, so refresh the connection's
// liveness fields manually or hocuspocus' message timeout would reap the
// real socket every `timeout` ms. connectionEstablishedAt is the
// reference while unauthenticated (auth for remote docs is proxied too)
// and is private upstream.
clientConnection.lastMessageReceivedAt = Date.now();
(clientConnection as any).connectionEstablishedAt = Date.now();
// another server owns the doc // another server owns the doc
const proxyMessage: RSAMessageProxy = { const proxyMessage: RSAMessageProxy = {
serializedHTTPRequest: serializedHTTPRequest, serializedHTTPRequest: serializedHTTPRequest,
@@ -386,17 +338,16 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
return; return;
} }
// This server owns the document, but hocuspocus hasn't loaded it yet // This server owns the document, but hocuspocus hasn't loaded it yet
clientConnection.handleMessage(message); ws.emit('message', message);
} }
onSocketClose(socketId: string, code?: number, reason?: ArrayBuffer) { onSocketClose(socketId: string, code?: number, reason?: ArrayBuffer) {
const entry = this.originConnections[socketId]; const socket = this.originSockets[socketId];
if (!entry) return; if (!socket) return;
delete this.originConnections[socketId]; // at this point the socket is considered GC'd and we cannot call close
entry.clientConnection.handleClose({ // The origin socket did not set up any connections for the proxy, so none of the hooks will work if we just emit
code: code ?? 1000, socket?.emit('close', code, reason);
reason: reason ? Buffer.from(reason).toString() : '', delete this.originSockets[socketId];
});
const msg: RSAMessageCloseProxy = { type: 'closeProxy', socketId }; const msg: RSAMessageCloseProxy = { type: 'closeProxy', socketId };
this.pub.publish(this.msgChannel, this.pack(msg)).catch(() => {}); this.pub.publish(this.msgChannel, this.pack(msg)).catch(() => {});
} }
@@ -421,7 +372,6 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
} }
async onDestroy() { async onDestroy() {
this.pendingReplies = {};
this.pub.disconnect(false); this.pub.disconnect(false);
this.sub.disconnect(false); this.sub.disconnect(false);
} }

Some files were not shown because too many files have changed in this diff Show More