Compare commits

...

57 Commits

Author SHA1 Message Date
Philipinho a86890d856 lint 2026-02-05 14:17:32 -08:00
Philipinho bef23b6738 feat(ee): public sharing controls 2026-02-05 14:14:18 -08:00
Philip Okugbe 5c3942c159 fix safari print (#1907) 2026-02-04 08:26:03 -08:00
Philipinho e0809e7104 v0.25.1 2026-02-04 07:10:13 -08:00
Philipinho da6793ac87 downgrade tiptap version (fix menu) 2026-02-04 07:09:48 -08:00
Philip Okugbe 08e94eb3c1 update dependencies (#1902) 2026-02-03 15:15:23 -08:00
Philipinho 5a14186f1c fix global diff css 2026-02-03 13:47:56 -08:00
Philipinho 6a0bb8d4cb v0.25.0 2026-02-03 13:18:03 -08:00
Philip Okugbe fba9f4cb2b New Crowdin updates (#1896)
* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2026-02-03 13:16:27 -08:00
Philipinho d8f7c4a822 cleanup 2026-02-03 13:12:39 -08:00
Philipinho 202685b39f fix translation 2026-02-03 13:09:56 -08:00
Philip Okugbe fc4a428208 fix(deps): update dependencies (#1898) 2026-02-03 13:04:00 -08:00
Philip Okugbe 5506eb194b feat: page history diff (#1891)
* Show actual history changes
* V2 - WIP
* feat: page history diff
* fix: exclude content from history listing

---------

Co-authored-by: Jason Norwood-Young <jason@10layer.com>
2026-02-03 11:55:20 -08:00
Philipinho f32bb298e0 v0.25.0-beta.1 2026-01-30 23:09:01 +00:00
Pleasure1234 3178cad796 fix: handle empty replace term in search and replace functionality (#1562)
- Fix 'Empty text nodes are not allowed' error when replace field is empty
- Update both replace() and replaceAll() functions to check for empty replaceTerm
2026-01-30 22:37:22 +00:00
Philipinho 9d7f8c62c5 sync 2026-01-30 22:31:49 +00:00
Philip Okugbe 78b1c1a453 feat: switch to cursor pagination (#1884)
* add cursor pagination function

* support custom order modifier
* refactor returned object

* feat(db): migrate paginated endpoints to cursor-based pagination

* sync

* support hasPrevPage boolean

* feat(client): migrate pagination from offset to cursor-based

* support beforeCursor/prevCursor

* wrap search results in items array for API consistency
2026-01-30 19:28:54 +00:00
Philip Okugbe 96ed98619f feat: add IPv6 support via configurable HOST binding (#1885) 2026-01-30 00:33:10 +00:00
Philip Okugbe 60501de992 fix: missing logs on OnApplicationBootstrap hook (#1882)
* - fix: set default Nest logger and bufferLogs to false for pino compatibility
- handle redis error event

* fix collab server logging too
2026-01-29 09:25:23 +00:00
Philip Okugbe 74e915546b feat: collab redis extension with server affinity (#1873)
* feat(collab): better redis extension
* move types to own file
* debug logging
* fix: graceful collab shutdown
* rename default prefix
* pass wsAdapter to gateway
* expose event handler
* unique collab serverId generation
* uninstall @hocuspocus/extension-redis package
* expose more functions
* sync with latest
* cleanup
* fastify router options
* cleanup type
2026-01-27 17:05:05 +00:00
Philipinho 3523600f40 add timestamps 2026-01-27 16:49:22 +00:00
Philip Okugbe 6ccb2bb872 feat(export): add metadata file to preserve page icons and ordering on import (#1877)
* feat(export): add metadata file to preserve page icons and ordering on import
- Export includes `docmost-metadata.json`
- Import reads metadata to restore icons and sort siblings by original position

* cleanup

* bonus fixes

* handle unknown prosemirror nodes

* add docmost app  version
2026-01-27 16:39:39 +00:00
Philipinho 0245a183e1 sync 2026-01-26 02:08:54 +00:00
Philip Okugbe de5f71894a New Crowdin updates (#1869)
* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2026-01-25 12:39:19 +00:00
Philip Okugbe 351b075ebb fix(tree): update sidebar-pages cache directly instead of refetching on page move (#1870) 2026-01-25 12:38:44 +00:00
Philipinho 1ca7d42203 fix switch space toggle 2026-01-25 02:49:25 +00:00
Philipinho 1e441560f6 fix production logs filter 2026-01-25 02:15:10 +00:00
Philip Okugbe 54775f537d fix: handle malformed URLs gracefully during import/export (#1868)
* Handling malformed URLs gracefully

* Allow import of invalid URLs, but adding logging.

---------

Co-authored-by: gpapp <gergely.papp@itworks.hu>
2026-01-25 00:48:43 +00:00
Philipinho 5dbf0027bd Add isomorphic basename utility 2026-01-25 00:08:02 +00:00
Philip Okugbe 5588ec34fb New Crowdin updates (#1866)
* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)
2026-01-25 00:04:50 +00:00
Philipinho 55b8128829 Fix Google sheets regex 2026-01-24 23:35:04 +00:00
Philip Okugbe aa6a046aa6 feat(export): add export loading state and copy as markdown (#1867)
* feat: add loading state to export

* feat: copy as markdown

* preserve taskList comment
2026-01-24 23:30:17 +00:00
Philip Okugbe 657fdf8cb7 feat: Tiptap V3 migration (#1854)
* Tiptap3 migration - WIP

* fix collaboration

* remove unused code

* fix flicker

* disable duplicate extensions

* update tiptap version

* Switch to useEditorState
- Set shouldRerenderOnTransaction to false

* fix editable state

* add tippyoptions for reference

* merge main

* tiptap 3.6.1

* fix bubble menu

* fix converter

* fix menus

* fix collaboration caret css

* fix: Set `isInitialized` to force immediate react node view rendering

* feat: Migrate tippy.js menus to Floating UI

* feat: Update collaboration connection for HocusPocus v3

* fix: Connect/disconnect websocketProvider

* cleanup

* cleanup

* feat: Improved placeholder and upload handling for images

* feat: Improved placeholder and upload handling for videos

* refactor: Image node and view clean-up

* feat: Improved placeholder and upload handling for attachments

* fix: Video view styles

* fix: Transaction handling on asset upload

* fix: Use imageDimensionsFromStream

* feat: Multiple file upload, improved placeholders, local previews

* fix: Drag & drop, paste upload

* fix: Allow media as attachment

* * add skeleton pulse animation
* add translation strings
* fix attachment view responsiveness

* fix collab connection status display

* Tiptap v3.17.0

* fix suggestion menu exit bug

* fix search shortcut

* fix history editor css

* tiptap 3.17.1

---------

Co-authored-by: Arek Nawo <areknawo@areknawo.com>
2026-01-24 20:41:08 +00:00
Philip Okugbe 98f71c95fe feat: stream file serving (#1865) 2026-01-24 17:54:56 +00:00
Philip Okugbe efb0a9317b feat: allow upload of large files (#1862)
* Allow upload of large files

* feat: createByteCountingStream utility function.

---------

Co-authored-by: gpapp <gergely.papp@itworks.hu>
2026-01-22 20:00:58 +00:00
Philipinho 063ea99b66 sync 2026-01-21 18:17:48 +00:00
Philip Okugbe aa143ad79c refactor(db): migrate from node-postgres to postgres.js (#1846)
* refactor(db): migrate from node-postgres to postgres.js
* ignore schema param
2026-01-21 18:12:16 +00:00
Philip Okugbe 918f4508d2 feat: switch to pino for logs (#1855)
- switch to json logs in production
- add option to support http logging
2026-01-21 01:23:50 +00:00
Philipinho 5cd0ba6902 fix script 2026-01-20 22:36:19 +00:00
Philipinho a1260188ae fix: UI improvements 2026-01-19 21:05:34 +00:00
Philipinho bdf02f593d Merge branch 'feat/auto-tooltip' 2026-01-19 19:43:58 +00:00
Philipinho e24bf5ed57 feat: auto-tooltip component 2026-01-19 19:40:06 +00:00
Philip Okugbe f3f74c591f fix(share): escape page title in SEO meta tags (#1850) 2026-01-19 19:31:28 +00:00
Philipinho 5f966a2d89 chore: add clean up command 2026-01-18 16:50:51 +00:00
Philipinho bcb004af21 update lockfile 2026-01-16 13:22:41 +00:00
Philipinho ac675e7d74 update dockerfile 2026-01-16 13:21:42 +00:00
Philipinho bf89eff5e7 sync 2026-01-16 13:20:31 +00:00
Philip Okugbe 183787fa0c fix: update dependencies (#1843) 2026-01-14 16:36:47 +00:00
Philipinho 15aa04a5f7 sync 2026-01-14 11:49:39 +00:00
Philipinho 79343a5d52 fix: prevent text overflow in group and space list tables 2026-01-13 16:25:42 +00:00
Philipinho 61e252918e fix length 2026-01-13 16:13:52 +00:00
Philipinho e98fa7f69a sync
* fix form length
2026-01-13 16:13:04 +00:00
Philip Okugbe 6d148a35eb New Crowdin updates (#1830)
* New translations translation.json (Japanese)

* New translations translation.json (Japanese)
2026-01-13 16:01:08 +00:00
Philip Okugbe 0bbc1c35de fix: public sharing performance improvements (#1841) 2026-01-13 16:00:22 +00:00
Philip Okugbe 47097969a0 fix: use subquery (#1833)
- enhance file tasks list endpoint
2026-01-13 15:58:26 +00:00
Philip Okugbe 13f529e064 fix anchor scroll in same page (#1834) 2026-01-13 15:35:53 +00:00
Philip Okugbe 8fc8422fbc fix: increase max length for groups and spaces (#1840) 2026-01-13 15:31:03 +00:00
247 changed files with 9305 additions and 5087 deletions
+3 -2
View File
@@ -1,5 +1,6 @@
node_modules node_modules
.git .git
.gitignore
dist dist
data /data
.env*
.nx
+7 -1
View File
@@ -46,4 +46,10 @@ DRAWIO_URL=
DISABLE_TELEMETRY=false DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false) # Enable debug logging in production (default: false)
DEBUG_MODE=false DEBUG_MODE=false
# Log database queries
DEBUG_DB=false
# Log http requests
LOG_HTTP=false
+3 -3
View File
@@ -1,13 +1,14 @@
FROM node:22-slim AS base FROM node:22-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@10.4.0
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN npm install -g pnpm@10.4.0
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
RUN pnpm build RUN pnpm build
@@ -31,12 +32,11 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
# Copy root package files # Copy root package files
COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/ COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches # Copy patches
COPY --from=builder /app/patches /app/patches COPY --from=builder /app/patches /app/patches
RUN npm install -g pnpm@10.4.0
RUN chown -R node:node /app RUN chown -R node:node /app
USER node USER node
+21 -24
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.24.1", "version": "0.25.1",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -10,52 +10,49 @@
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"" "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0", "@casl/react": "^4.0.0",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b", "@excalidraw/excalidraw": "0.18.0-c158187",
"@mantine/core": "^8.1.3", "@mantine/core": "^8.3.12",
"@mantine/dates": "^8.3.2", "@mantine/dates": "^8.3.12",
"@mantine/form": "^8.1.3", "@mantine/form": "^8.3.12",
"@mantine/hooks": "^8.1.3", "@mantine/hooks": "^8.3.12",
"@mantine/modals": "^8.1.3", "@mantine/modals": "^8.3.12",
"@mantine/notifications": "^8.1.3", "@mantine/notifications": "^8.3.12",
"@mantine/spotlight": "^8.1.3", "@mantine/spotlight": "^8.3.12",
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.90.17",
"@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0", "highlightjs-sap-abap": "^0.3.0",
"i18next": "^23.14.0", "i18next": "^23.16.8",
"i18next-http-backend": "^2.6.1", "i18next-http-backend": "^2.7.3",
"jotai": "^2.12.5", "jotai": "^2.16.2",
"jotai-optics": "^0.4.0", "jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "0.16.22", "katex": "0.16.27",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.11.0", "mermaid": "^11.12.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "^1.255.1", "posthog-js": "^1.255.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.15", "react-clear-modal": "^2.0.17",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drawio": "^1.0.1", "react-drawio": "^1.0.7",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1", "react-i18next": "^15.0.1",
"react-router-dom": "^7.0.1", "react-router-dom": "^7.12.0",
"semver": "^7.7.2", "semver": "^7.7.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.3",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.", "Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.", "Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
"Confirm": "Bestätigen", "Confirm": "Bestätigen",
"Copy as Markdown": "Als Markdown kopieren",
"Copy link": "Link kopieren", "Copy link": "Link kopieren",
"Create": "Erstellen", "Create": "Erstellen",
"Create group": "Gruppe erstellen", "Create group": "Gruppe erstellen",
@@ -122,6 +123,8 @@
"page": "Seite", "page": "Seite",
"Page deleted successfully": "Seite erfolgreich gelöscht", "Page deleted successfully": "Seite erfolgreich gelöscht",
"Page history": "Seitengeschichte", "Page history": "Seitengeschichte",
"Select version": "Version auswählen",
"Highlight changes": "Änderungen hervorheben",
"Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.", "Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.",
"Pages": "Seiten", "Pages": "Seiten",
"pages": "Seiten", "pages": "Seiten",
@@ -234,7 +237,9 @@
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.", "Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
"Invite link": "Einladungslink", "Invite link": "Einladungslink",
"Copy": "Kopieren", "Copy": "Kopieren",
"Copy to space": "In Raum kopieren",
"Copied": "Kopiert", "Copied": "Kopiert",
"Duplicate": "Duplizieren",
"Select a user": "Benutzer auswählen", "Select a user": "Benutzer auswählen",
"Select a group": "Gruppe auswählen", "Select a group": "Gruppe auswählen",
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.", "Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
@@ -251,6 +256,7 @@
"Export failed:": "Export fehlgeschlagen:", "Export failed:": "Export fehlgeschlagen:",
"export error": "Exportfehler", "export error": "Exportfehler",
"Export page": "Seite exportieren", "Export page": "Seite exportieren",
"Export successful": "Export erfolgreich",
"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}}",
@@ -326,6 +332,8 @@
"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 file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.", "Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
"Uploading {{name}}": "Lade {{name}} hoch",
"Uploading file": "Datei wird hochgeladen",
"Table": "Tabelle", "Table": "Tabelle",
"Insert a table.": "Tabelle einfügen.", "Insert a table.": "Tabelle einfügen.",
"Insert collapsible block.": "Einklappbaren Block einfügen.", "Insert collapsible block.": "Einklappbaren Block einfügen.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.", "Choose your preferred page width.": "Choose your preferred page width.",
"Confirm": "Confirm", "Confirm": "Confirm",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link", "Copy link": "Copy link",
"Create": "Create", "Create": "Create",
"Create group": "Create group", "Create group": "Create group",
@@ -122,6 +123,8 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page deleted successfully", "Page deleted successfully": "Page deleted successfully",
"Page history": "Page history", "Page history": "Page history",
"Select version": "Select version",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
"Pages": "Pages", "Pages": "Pages",
"pages": "pages", "pages": "pages",
@@ -253,6 +256,7 @@
"Export failed:": "Export failed:", "Export failed:": "Export failed:",
"export error": "export error", "export error": "export error",
"Export page": "Export page", "Export page": "Export page",
"Export successful": "Export successful",
"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",
@@ -328,6 +332,8 @@
"Upload any image from your device.": "Upload any image from your device.", "Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.", "Upload any video from your device.": "Upload any video from your device.",
"Upload any file from your device.": "Upload any file from your device.", "Upload any file from your device.": "Upload any file from your device.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Table", "Table": "Table",
"Insert a table.": "Insert a table.", "Insert a table.": "Insert a table.",
"Insert collapsible block.": "Insert collapsible block.", "Insert collapsible block.": "Insert collapsible block.",
@@ -401,6 +407,21 @@
"Share deleted successfully": "Share deleted successfully", "Share deleted successfully": "Share deleted successfully",
"Share not found": "Share not found", "Share not found": "Share not found",
"Failed to share page": "Failed to share page", "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",
"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.",
"Requires an enterprise license": "Requires 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": "Copy page",
"Copy page to a different space.": "Copy page to a different space.", "Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully", "Page copied successfully": "Page copied successfully",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.", "Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
"Choose your preferred page width.": "Elige el ancho de página que prefieras.", "Choose your preferred page width.": "Elige el ancho de página que prefieras.",
"Confirm": "Confirmar", "Confirm": "Confirmar",
"Copy as Markdown": "Copiar como Markdown",
"Copy link": "Copiar enlace", "Copy link": "Copiar enlace",
"Create": "Crear", "Create": "Crear",
"Create group": "Crear grupo", "Create group": "Crear grupo",
@@ -122,6 +123,8 @@
"page": "página", "page": "página",
"Page deleted successfully": "Página eliminada con éxito", "Page deleted successfully": "Página eliminada con éxito",
"Page history": "Historial de la página", "Page history": "Historial de la página",
"Select version": "Seleccionar versión",
"Highlight changes": "Resaltar cambios",
"Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.", "Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.",
"Pages": "Páginas", "Pages": "Páginas",
"pages": "páginas", "pages": "páginas",
@@ -253,6 +256,7 @@
"Export failed:": "Exportación fallida:", "Export failed:": "Exportación fallida:",
"export error": "error de exportación", "export error": "error de exportación",
"Export page": "Exportar página", "Export page": "Exportar página",
"Export successful": "Exportación exitosa",
"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",
@@ -328,6 +332,8 @@
"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 file from your device.": "Sube cualquier archivo desde tu dispositivo.", "Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
"Uploading {{name}}": "Subiendo {{name}}",
"Uploading file": "Subiendo archivo",
"Table": "Tabla", "Table": "Tabla",
"Insert a table.": "Insertar una tabla.", "Insert a table.": "Insertar una tabla.",
"Insert collapsible block.": "Insertar bloque desplegable.", "Insert collapsible block.": "Insertar bloque desplegable.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.", "Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.",
"Choose your preferred page width.": "Choisissez votre largeur de page préférée.", "Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
"Confirm": "Confirmer", "Confirm": "Confirmer",
"Copy as Markdown": "Copier comme Markdown",
"Copy link": "Copier le lien", "Copy link": "Copier le lien",
"Create": "Créer", "Create": "Créer",
"Create group": "Créer groupe", "Create group": "Créer groupe",
@@ -122,6 +123,8 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page supprimée avec succès", "Page deleted successfully": "Page supprimée avec succès",
"Page history": "Historique de la page", "Page history": "Historique de la page",
"Select version": "Sélectionner la version",
"Highlight changes": "Mettre en évidence les changements",
"Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.", "Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.",
"Pages": "Pages", "Pages": "Pages",
"pages": "pages", "pages": "pages",
@@ -253,6 +256,7 @@
"Export failed:": "Échec de l'exportation :", "Export failed:": "Échec de l'exportation :",
"export error": "exporter l'erreur", "export error": "exporter l'erreur",
"Export page": "Exporter la page", "Export page": "Exporter la page",
"Export successful": "Exportation réussie",
"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",
@@ -328,6 +332,8 @@
"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 file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.", "Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
"Uploading {{name}}": "Téléchargement de {{name}}",
"Uploading file": "Téléchargement du fichier",
"Table": "Tableau", "Table": "Tableau",
"Insert a table.": "Insérez un tableau.", "Insert a table.": "Insérez un tableau.",
"Insert collapsible block.": "Insérer un bloc repliable.", "Insert collapsible block.": "Insérer un bloc repliable.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.", "Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.", "Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
"Confirm": "Conferma", "Confirm": "Conferma",
"Copy as Markdown": "Copia come Markdown",
"Copy link": "Copia link", "Copy link": "Copia link",
"Create": "Crea", "Create": "Crea",
"Create group": "Crea gruppo", "Create group": "Crea gruppo",
@@ -122,6 +123,8 @@
"page": "pagina", "page": "pagina",
"Page deleted successfully": "Pagina eliminata con successo", "Page deleted successfully": "Pagina eliminata con successo",
"Page history": "Cronologia della pagina", "Page history": "Cronologia della pagina",
"Select version": "Seleziona versione",
"Highlight changes": "Evidenzia modifiche",
"Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.", "Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.",
"Pages": "Pagine", "Pages": "Pagine",
"pages": "pagine", "pages": "pagine",
@@ -253,6 +256,7 @@
"Export failed:": "Esportazione fallita:", "Export failed:": "Esportazione fallita:",
"export error": "errore di esportazione", "export error": "errore di esportazione",
"Export page": "Esporta pagina", "Export page": "Esporta pagina",
"Export successful": "Esportazione riuscita",
"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}}",
@@ -328,6 +332,8 @@
"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 file from your device.": "Carica qualsiasi file dal tuo dispositivo.", "Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
"Uploading {{name}}": "Caricamento di {{name}}",
"Uploading file": "Caricamento file",
"Table": "Tabella", "Table": "Tabella",
"Insert a table.": "Inserisci una tabella.", "Insert a table.": "Inserisci una tabella.",
"Insert collapsible block.": "Inserisci blocco comprimibile.", "Insert collapsible block.": "Inserisci blocco comprimibile.",
+150 -144
View File
@@ -13,22 +13,23 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。", "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "このユーザをスペースから削除してもよろしいですか? ユーザはこのスペースへのアクセス権をすべて失います。", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "このユーザをスペースから削除してもよろしいですか? ユーザはこのスペースへのアクセス権をすべて失います。",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。",
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになることができます", "Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになます",
"Can create and edit pages in space.": "スペース内のページを作成および編集できます", "Can create and edit pages in space.": "スペース内のページを作成編集できます",
"Can edit": "編集可能", "Can edit": "編集可能",
"Can manage workspace": "ワークスペースを管理できます", "Can manage workspace": "ワークスペースを管理できます",
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません", "Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
"Can view": "閲覧可能", "Can view": "閲覧可能",
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません", "Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
"Cancel": "キャンセル", "Cancel": "キャンセル",
"Change email": "メールアドレスの変更", "Change email": "メールアドレスの変更",
"Change password": "パスワードの変更", "Change password": "パスワードの変更",
"Change photo": "画像の変更", "Change photo": "画像の変更",
"Choose a role": "ロールを選んでください", "Choose a role": "ロールを選んでください",
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください", "Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
"Choose your preferred interface language.": "お好みのインターフェース言語を選択してください", "Choose your preferred interface language.": "お好みの言語を選択してください",
"Choose your preferred page width.": "左右の余白を縮小する場合はオンにしてください", "Choose your preferred page width.": "お好みのページ幅を選択してください",
"Confirm": "確認", "Confirm": "確認",
"Copy as Markdown": "Markdownとしてコピー",
"Copy link": "リンクをコピー", "Copy link": "リンクをコピー",
"Create": "新規作成", "Create": "新規作成",
"Create group": "グループを作成", "Create group": "グループを作成",
@@ -40,24 +41,24 @@
"Date": "日付", "Date": "日付",
"Delete": "削除", "Delete": "削除",
"Delete group": "グループを削除", "Delete group": "グループを削除",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?この操作により、子ページおよびページ履歴削除されます。この操作は元に戻せません。", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?子ページページ履歴削除されます。この操作は取り消せません。",
"Description": "説明", "Description": "説明",
"Details": "詳細", "Details": "詳細",
"e.g ACME": "例: 山田太郎", "e.g ACME": "例: 山田太郎",
"e.g ACME Inc": "例: 株式会社サンプル", "e.g ACME Inc": "例: 株式会社サンプル",
"e.g Developers": "例: エンジニア", "e.g Developers": "例: エンジニア",
"e.g Group for developers": "例: エンジニアグループ", "e.g Group for developers": "例: 開発チーム",
"e.g product": "例: product", "e.g product": "例: product",
"e.g Product Team": "例: 製品チーム", "e.g Product Team": "例: プロダクトチーム",
"e.g Sales": "例: 営業", "e.g Sales": "例: 営業",
"e.g Space for product team": "例: 製品チームスペース", "e.g Space for product team": "例: プロダクトチームスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース", "e.g Space for sales team to collaborate": "例: 営業チーム用スペース",
"Edit": "編集", "Edit": "編集",
"Read": "読む", "Read": "閲覧",
"Edit group": "グループを編集", "Edit group": "グループを編集",
"Email": "メールアドレス", "Email": "メールアドレス",
"Enter a strong password": "強力なパスワードを入力してください", "Enter a strong password": "強力なパスワードを入力してください",
"Enter valid email addresses separated by comma or space max_50": "有効なメールアドレスをカンマまたはスペース区切って入力してください(最大 50", "Enter valid email addresses separated by comma or space max_50": "メールアドレスをカンマまたはスペース区切りで入力(最大50",
"enter valid emails addresses": "有効なメールアドレスを入力してください", "enter valid emails addresses": "有効なメールアドレスを入力してください",
"Enter your current password": "現在のパスワードを入力してください", "Enter your current password": "現在のパスワードを入力してください",
"enter your full name": "氏名を入力してください", "enter your full name": "氏名を入力してください",
@@ -81,18 +82,18 @@
"Group description": "グループ説明", "Group description": "グループ説明",
"Group name": "グループ名", "Group name": "グループ名",
"Groups": "グループ", "Groups": "グループ",
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます", "Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
"Home": "ホーム", "Home": "ホーム",
"Import pages": "ページをインポート", "Import pages": "ページをインポート",
"Import pages & space settings": "ページとスペース設定をインポート", "Import pages & space settings": "ページとスペース設定をインポート",
"Importing pages": "ページをインポートしています", "Importing pages": "ページをインポートしています",
"invalid invitation link": "招待リンクが間違っています", "invalid invitation link": "無効な招待リンクす",
"Invitation signup": "招待登録", "Invitation signup": "招待登録",
"Invite by email": "メールアドレスで招待する", "Invite by email": "メールアドレスで招待する",
"Invite members": "メンバーを招待する", "Invite members": "メンバーを招待する",
"Invite new members": "新しいメンバーを招待する", "Invite new members": "新しいメンバーを招待する",
"Invited members who are yet to accept their invitation will appear here.": "招待をまだ承諾していないメンバーここに表示されます", "Invited members who are yet to accept their invitation will appear here.": "招待を承諾していないメンバーここに表示されます",
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセス権が付与されます", "Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセスできます",
"Join the workspace": "ワークスペースに参加", "Join the workspace": "ワークスペースに参加",
"Language": "言語", "Language": "言語",
"Light": "ライト", "Light": "ライト",
@@ -113,20 +114,22 @@
"New page": "新規ページ", "New page": "新規ページ",
"New password": "新しいパスワード", "New password": "新しいパスワード",
"No group found": "グループが見つかりません", "No group found": "グループが見つかりません",
"No page history saved yet.": "まだページ履歴が保存されていません", "No page history saved yet.": "ページ履歴がありません",
"No pages yet": "ページがありません", "No pages yet": "ページがありません",
"No results found...": "結果が見つかりませんでした...", "No results found...": "結果が見つかりません",
"No user found": "ユーザがいません", "No user found": "ユーザーが見つかりません",
"Overview": "概要", "Overview": "概要",
"Owner": "所有者", "Owner": "所有者",
"page": "ページ", "page": "ページ",
"Page deleted successfully": "ページが正常に削除されました", "Page deleted successfully": "ページを削除しました",
"Page history": "ページ履歴", "Page history": "ページ履歴",
"Page import is in progress. Please do not close this tab.": "ージのインポートが進行中です。このタブを閉じないでください。", "Select version": "ージョンを選択",
"Highlight changes": "変更を強調表示",
"Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください",
"Pages": "ページ", "Pages": "ページ",
"pages": "ページ", "pages": "ページ",
"Password": "パスワード", "Password": "パスワード",
"Password changed successfully": "パスワードが正常に変更されました", "Password changed successfully": "パスワードを変更しました",
"Pending": "保留中", "Pending": "保留中",
"Please confirm your action": "アクションを確認してください", "Please confirm your action": "アクションを確認してください",
"Preferences": "設定", "Preferences": "設定",
@@ -143,95 +146,95 @@
"Search for groups": "グループを検索", "Search for groups": "グループを検索",
"Search for users": "ユーザーを検索", "Search for users": "ユーザーを検索",
"Search for users and groups": "ユーザーとグループを検索", "Search for users and groups": "ユーザーとグループを検索",
"Search...": "検索する語句を入力", "Search...": "検索",
"Select language": "言語を選択", "Select language": "言語を選択",
"Select role": "ロールを選択", "Select role": "ロールを選択",
"Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください", "Select role to assign to all invited members": "招待するメンバーに割り当てるロールを選択",
"Select theme": "テーマを選択", "Select theme": "テーマを選択",
"Send invitation": "招待を送る", "Send invitation": "招待を送る",
"Invitation sent": "招待送信されました", "Invitation sent": "招待送信ました",
"Settings": "設定", "Settings": "設定",
"Setup workspace": "ワークスペースを設定する", "Setup workspace": "ワークスペースを設定する",
"Sign In": "サインイン", "Sign In": "サインイン",
"Sign Up": "アカウント登録", "Sign Up": "新規登録",
"Slug": "Slug (URL用文字列)", "Slug": "スラッグ(URL識別子)",
"Space": "スペース", "Space": "スペース",
"Space description": "スペース説明", "Space description": "スペース説明",
"Space menu": "スペースメニュー", "Space menu": "スペースメニュー",
"Space name": "スペース名", "Space name": "スペース名",
"Space settings": "スペース設定", "Space settings": "スペース設定",
"Space slug": "スペースのSlug (URL用文字列)", "Space slug": "スペースのスラッグ(URL識別子)",
"Spaces": "スペース", "Spaces": "スペース",
"Spaces you belong to": "所属しているスペース", "Spaces you belong to": "所属しているスペース",
"No space found": "スペースが見つかりません", "No space found": "スペースが見つかりません",
"Search for spaces": "スペースを検索", "Search for spaces": "スペースを検索",
"Start typing to search...": "検索を開始するには入力してください...", "Start typing to search...": "入力して検索",
"Status": "ステータス", "Status": "ステータス",
"Successfully imported": "インポートに成功しました", "Successfully imported": "インポートしました",
"Successfully restored": "正常に復元されました", "Successfully restored": "復元しました",
"System settings": "システム設定", "System settings": "システム設定",
"Theme": "テーマ", "Theme": "テーマ",
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力する必要があります。", "To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力してください",
"Toggle full page width": "ページ幅を切り替え", "Toggle full page width": "ページ幅を切り替え",
"Unable to import pages. Please try again.": "ページをインポートできません。もう一度お試しください", "Unable to import pages. Please try again.": "ページをインポートできませんでした。もう一度お試しください",
"untitled": "無題", "untitled": "無題",
"Untitled": "無題", "Untitled": "無題",
"Updated successfully": "正常に更新されました", "Updated successfully": "更新しました",
"User": "ユーザー", "User": "ユーザー",
"Workspace": "ワークスペース", "Workspace": "ワークスペース",
"Workspace Name": "ワークスペース名", "Workspace Name": "ワークスペース名",
"Workspace settings": "ワークスペース設定", "Workspace settings": "ワークスペース設定",
"You can change your password here.": "パスワードを変更できます", "You can change your password here.": "パスワードを変更できます",
"Your Email": "メールアドレス", "Your Email": "メールアドレス",
"Your import is complete.": "インポートが完了しました", "Your import is complete.": "インポートが完了しました",
"Your name": "名前", "Your name": "名前",
"Your Name": "名前", "Your Name": "名前",
"Your password": "パスワード", "Your password": "パスワード",
"Your password must be a minimum of 8 characters.": "パスワードは最低 8 文字必要です。", "Your password must be a minimum of 8 characters.": "パスワードは8文字以上にしてください",
"Sidebar toggle": "サイドバー切り替え", "Sidebar toggle": "サイドバー切り替え",
"Comments": "コメント", "Comments": "コメント",
"404 page not found": "404 ページが見つかりません", "404 page not found": "404 ページが見つかりません",
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません", "Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません",
"Take me back to homepage": "ホームに戻る", "Take me back to homepage": "ホームに戻る",
"Forgot password": "パスワードを忘れた", "Forgot password": "パスワードを忘れた",
"Forgot your password?": "パスワードを忘れましたか?", "Forgot your password?": "パスワードを忘れましたか?",
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセットリンクがあなたのメールアドレスに送信されました。受信を確認してください", "A password reset link has been sent to your email. Please check your inbox.": "パスワードリセット用のリンクをメールに送信ました。受信トレイを確認してください",
"Send reset link": "リセットリンクを送", "Send reset link": "リセットリンクを送",
"Password reset": "パスワードリセット", "Password reset": "パスワードリセット",
"Your new password": "新しいパスワード", "Your new password": "新しいパスワード",
"Set password": "パスワードを設定", "Set password": "パスワードを設定",
"Write a comment": "コメントを書く", "Write a comment": "コメントを書く",
"Reply...": "返信...", "Reply...": "返信...",
"Error loading comments.": "コメントの読み込み中にエラーが発生しました", "Error loading comments.": "コメントの読み込みに失敗しました",
"No comments yet.": "コメントがありません", "No comments yet.": "コメントがありません",
"Edit comment": "コメントを編集する", "Edit comment": "コメントを編集する",
"Delete comment": "コメントを削除する", "Delete comment": "コメントを削除する",
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?", "Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
"Comment created successfully": "コメント作成されました", "Comment created successfully": "コメント作成ました",
"Error creating comment": "コメントの作成中にエラーが発生しました", "Error creating comment": "コメントの作成に失敗しました",
"Comment updated successfully": "コメント更新されました", "Comment updated successfully": "コメント更新ました",
"Failed to update comment": "コメントの更新に失敗しました", "Failed to update comment": "コメントの更新に失敗しました",
"Comment deleted successfully": "コメント削除されました", "Comment deleted successfully": "コメント削除ました",
"Failed to delete comment": "コメントの削除に失敗しました", "Failed to delete comment": "コメントの削除に失敗しました",
"Comment resolved successfully": "コメント解決されました", "Comment resolved successfully": "コメント解決ました",
"Comment re-opened successfully": "コメント再開されました", "Comment re-opened successfully": "コメント再開ました",
"Comment unresolved successfully": "コメントが再解決されました", "Comment unresolved successfully": "コメントを未解決に戻しました",
"Failed to resolve comment": "コメントの解決に失敗しました", "Failed to resolve comment": "コメントの解決に失敗しました",
"Resolve comment": "コメントを解決", "Resolve comment": "コメントを解決",
"Unresolve comment": "コメントを解決", "Unresolve comment": "コメントを解決に戻す",
"Resolve Comment Thread": "コメントスレッドを解決", "Resolve Comment Thread": "コメントスレッドを解決",
"Unresolve Comment Thread": "コメントスレッドを解決", "Unresolve Comment Thread": "コメントスレッドを解決に戻す",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか? これにより完了としてマークされます", "Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか完了としてマークされます",
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決しますか?", "Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決に戻しますか?",
"Resolved": "解決済", "Resolved": "解決済",
"No active comments.": "アクティブなコメントはありません", "No active comments.": "アクティブなコメントはありません",
"No resolved comments.": "解決されたコメントはありません", "No resolved comments.": "解決済みのコメントはありません",
"Revoke invitation": "招待を取り消す", "Revoke invitation": "招待を取り消す",
"Revoke": "取り消す", "Revoke": "取り消す",
"Don't": "取り消さない", "Don't": "取り消さない",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですか? ユーザはワークスペースに参加できなくなります", "Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですかユーザはワークスペースに参加できなくなります",
"Resend invitation": "招待を再度送る", "Resend invitation": "招待を再度送る",
"Anyone with this link can join this workspace.": "このリンクをっている人は誰でもこのワークスペースに参加できます", "Anyone with this link can join this workspace.": "このリンクをっている人は誰でもワークスペースに参加できます",
"Invite link": "招待リンク", "Invite link": "招待リンク",
"Copy": "コピー", "Copy": "コピー",
"Copy to space": "スペースにコピー", "Copy to space": "スペースにコピー",
@@ -239,13 +242,13 @@
"Duplicate": "複製", "Duplicate": "複製",
"Select a user": "ユーザを選択", "Select a user": "ユーザを選択",
"Select a group": "グループを選択", "Select a group": "グループを選択",
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします", "Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
"Delete space": "スペースを削除", "Delete space": "スペースを削除",
"Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?", "Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?",
"Delete this space with all its pages and data.": "このスペースおよびスペース内のすべてのページデータを削除します", "Delete this space with all its pages and data.": "このスペースすべてのページデータを削除します",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "このスペース内のすべてのページ、コメント、添付ファイル、および権限完全に削除されます", "All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "スペース内のすべてのページ、コメント、添付ファイル、権限完全に削除されます",
"Confirm space name": "スペース名を確認する", "Confirm space name": "スペース名を確認する",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "アクションを確認するためスペース名 <b>{{spaceName}}</b> を入力してください", "Type the space name <b>{{spaceName}}</b> to confirm your action.": "確認のためスペース名 <b>{{spaceName}}</b> を入力してください",
"Format": "フォーマット", "Format": "フォーマット",
"Include subpages": "サブページを含める", "Include subpages": "サブページを含める",
"Include attachments": "添付ファイルを含める", "Include attachments": "添付ファイルを含める",
@@ -253,6 +256,7 @@
"Export failed:": "エクスポートに失敗しました:", "Export failed:": "エクスポートに失敗しました:",
"export error": "エクスポートエラー", "export error": "エクスポートエラー",
"Export page": "エクスポートページ", "Export page": "エクスポートページ",
"Export successful": "エクスポート成功",
"Export space": "エクスポートスペース", "Export space": "エクスポートスペース",
"Export {{type}}": "{{type}}をエクスポート", "Export {{type}}": "{{type}}をエクスポート",
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています", "File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
@@ -273,12 +277,12 @@
"Success": "成功", "Success": "成功",
"Warning": "警告", "Warning": "警告",
"Danger": "危険", "Danger": "危険",
"Mermaid diagram error:": "Mermaid コードエラー", "Mermaid diagram error:": "Mermaid ダイアグラムエラー:",
"Invalid Mermaid diagram": "無効な Mermaid コードです", "Invalid Mermaid diagram": "無効な Mermaid ダイアグラムです",
"Double-click to edit Draw.io diagram": "ダブルクリックしてDraw.io図を編集", "Double-click to edit Draw.io diagram": "ダブルクリックして Draw.io 図を編集",
"Exit": "終了", "Exit": "終了",
"Save & Exit": "保存して終了", "Save & Exit": "保存して終了",
"Double-click to edit Excalidraw diagram": "ダブルクリックしてExcalidraw図を編集", "Double-click to edit Excalidraw diagram": "ダブルクリックして Excalidraw 図を編集",
"Paste link": "リンクを貼り付け", "Paste link": "リンクを貼り付け",
"Edit link": "リンクを編集", "Edit link": "リンクを編集",
"Remove link": "リンクを削除", "Remove link": "リンクを削除",
@@ -315,22 +319,24 @@
"Bullet List": "箇条書きリスト", "Bullet List": "箇条書きリスト",
"Numbered List": "番号付きリスト", "Numbered List": "番号付きリスト",
"Blockquote": "引用", "Blockquote": "引用",
"Just start typing with plain text.": "すぐに文章を書き始められます", "Just start typing with plain text.": "プレーンテキストを入力します",
"Track tasks with a to-do list.": "Todoリストでタスクを追跡します", "Track tasks with a to-do list.": "Todo リストでタスクを管理します",
"Big section heading.": "大きいフォントのセクション見出しです。", "Big section heading.": "大見出し",
"Medium section heading.": "中くらいのフォントのセクション見出しです。", "Medium section heading.": "中見出し",
"Small section heading.": "小さいフォントのセクション見出しです。", "Small section heading.": "小見出し",
"Create a simple bullet list.": "シンプルな箇条書きリストを作成します", "Create a simple bullet list.": "箇条書きリストを作成します",
"Create a list with numbering.": "番号付きリストを作成します", "Create a list with numbering.": "番号付きリストを作成します",
"Create block quote.": "引用を作成します", "Create block quote.": "引用ブロックを作成します",
"Insert code snippet.": "コードスニペットを入します", "Insert code snippet.": "コードスニペットを入します",
"Insert horizontal rule divider": "水平線を挿入します", "Insert horizontal rule divider": "区切り線を挿入します",
"Upload any image from your device.": "画像をアップロードします", "Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "動画をアップロードします", "Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any file from your device.": "ファイルをアップロードします", "Upload any file from your device.": "デバイスからファイルをアップロードします",
"Uploading {{name}}": "{{name}} をアップロード中",
"Uploading file": "ファイルをアップロード中",
"Table": "テーブル", "Table": "テーブル",
"Insert a table.": "を挿入します", "Insert a table.": "テーブルを挿入します",
"Insert collapsible block.": "折りたたみ可能なブロックを挿入します", "Insert collapsible block.": "折りたたみブロックを挿入します",
"Video": "動画", "Video": "動画",
"Divider": "区切り線", "Divider": "区切り線",
"Quote": "引用", "Quote": "引用",
@@ -338,16 +344,16 @@
"File attachment": "ファイル添付", "File attachment": "ファイル添付",
"Toggle block": "ブロックを切り替える", "Toggle block": "ブロックを切り替える",
"Callout": "コールアウト", "Callout": "コールアウト",
"Insert callout notice.": "コールアウトブロックを挿入します", "Insert callout notice.": "コールアウトを挿入します",
"Math inline": "インライン数式", "Math inline": "インライン数式",
"Insert inline math equation.": "インライン数式を挿入します", "Insert inline math equation.": "インライン数式を挿入します",
"Math block": "数式ブロック", "Math block": "数式ブロック",
"Insert math equation": "数式を挿入します", "Insert math equation": "数式を挿入します",
"Mermaid diagram": "Mermaidコード", "Mermaid diagram": "Mermaid ダイアグラム",
"Insert mermaid diagram": "Mermaidコードを記述して図を挿入します", "Insert mermaid diagram": "Mermaid ダイアグラムを挿入します",
"Insert and design Drawio diagrams": "Drawio図を挿入してデザインします", "Insert and design Drawio diagrams": "Draw.io 図を挿入・編集します",
"Insert current date": "今日の日付を挿入します", "Insert current date": "現在の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw図を埋め込みます", "Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
"Multiple": "複数", "Multiple": "複数",
"Heading {{level}}": "見出し {{level}}", "Heading {{level}}": "見出し {{level}}",
"Toggle title": "タイトルの表示/非表示を切り替える", "Toggle title": "タイトルの表示/非表示を切り替える",
@@ -357,29 +363,29 @@
"Yesterday, {{time}}": "昨日、{{time}}", "Yesterday, {{time}}": "昨日、{{time}}",
"Space created successfully": "スペースを作成しました", "Space created successfully": "スペースを作成しました",
"Space updated successfully": "スペースを更新しました", "Space updated successfully": "スペースを更新しました",
"Space deleted successfully": "スペース削除されました", "Space deleted successfully": "スペース削除ました",
"Members added successfully": "メンバーを追加しました", "Members added successfully": "メンバーを追加しました",
"Member removed successfully": "メンバー削除されました", "Member removed successfully": "メンバー削除ました",
"Member role updated successfully": "メンバーのロールを更新しました", "Member role updated successfully": "メンバーのロールを更新しました",
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>", "Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
"Created at: {{time}}": "作成しました:{{time}}", "Created at: {{time}}": "作成日: {{time}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}", "Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
"Word count: {{wordCount}}": "ワード数: {{wordCount}}", "Word count: {{wordCount}}": "単語数: {{wordCount}}",
"Character count: {{characterCount}}": "文字数: {{characterCount}}", "Character count: {{characterCount}}": "文字数: {{characterCount}}",
"New update": "新規更新", "New update": "新規更新",
"{{latestVersion}} is available": "{{latestVersion}}利用可能です", "{{latestVersion}} is available": "{{latestVersion}}利用可能です",
"Default page edit mode": "デフォルトのページ編集モード", "Default page edit mode": "デフォルトのページ編集モード",
"Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ます", "Choose your preferred page edit mode. Avoid accidental edits.": "お好みのページ編集モードを選択してください(誤編集を防止します",
"Reading": "読み取り", "Reading": "読み取り",
"Delete member": "メンバーを削除する", "Delete member": "メンバーを削除する",
"Member deleted successfully": "メンバー削除されました", "Member deleted successfully": "メンバー削除ました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません", "Are you sure you want to delete this workspace member? This action is irreversible.": "このメンバーを削除してもよろしいですか?この操作は取り消せません",
"Move": "移動", "Move": "移動",
"Move page": "ページを移動", "Move page": "ページを移動",
"Move page to a different space.": "ページを別のスペースに移動します", "Move page to a different space.": "ページを別のスペースに移動します",
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…", "Real-time editor connection lost. Retrying...": "リアルタイム編集の接続が切断されました。再接続中...",
"Table of contents": "目次", "Table of contents": "目次",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次生成ます", "Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加すると目次生成されます",
"Share": "共有", "Share": "共有",
"Public sharing": "公開共有", "Public sharing": "公開共有",
"Shared by": "共有者", "Shared by": "共有者",
@@ -398,13 +404,13 @@
"Delete share": "共有を削除", "Delete share": "共有を削除",
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?", "Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます", "Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
"Share deleted successfully": "共有が正常に削除されました", "Share deleted successfully": "共有を削除しました",
"Share not found": "共有が見つかりません", "Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました", "Failed to share page": "ページの共有に失敗しました",
"Copy page": "ページをコピー", "Copy page": "ページをコピー",
"Copy page to a different space.": "ページを別のスペースにコピーします", "Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページコピーに成功しました", "Page copied successfully": "ページコピーしました",
"Page duplicated successfully": "ページが正常に複製されました", "Page duplicated successfully": "ページを複製しました",
"Find": "検索", "Find": "検索",
"Not found": "見つかりません", "Not found": "見つかりません",
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)", "Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
@@ -419,26 +425,26 @@
"Error": "エラー", "Error": "エラー",
"Failed to disable MFA": "MFAの無効化に失敗しました", "Failed to disable MFA": "MFAの無効化に失敗しました",
"Disable two-factor authentication": "二要素認証を無効化", "Disable two-factor authentication": "二要素認証を無効化",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります", "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります",
"Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください:", "Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください",
"Two-factor authentication has been enabled": "二要素認証有効になりました", "Two-factor authentication has been enabled": "二要素認証有効にました",
"Two-factor authentication has been disabled": "二要素認証無効になりました", "Two-factor authentication has been disabled": "二要素認証無効にました",
"2-step verification": "2段階認", "2-step verification": "2段階認",
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証レイヤーでアカウントを保護します", "Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証でアカウントを保護します",
"Two-factor authentication is active on your account.": "二要素認証がアカウントで有効です", "Two-factor authentication is active on your account.": "二要素認証が有効です",
"Add 2FA method": "2FAメソッドを追加", "Add 2FA method": "2FAメソッドを追加",
"Backup codes": "バックアップコード", "Backup codes": "バックアップコード",
"Disable": "無効にする", "Disable": "無効にする",
"Invalid verification code": "無効な認証コード", "Invalid verification code": "無効な認証コード",
"New backup codes have been generated": "新しいバックアップコード生成されました", "New backup codes have been generated": "新しいバックアップコード生成ました",
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました", "Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
"About backup codes": "バックアップコードについて", "About backup codes": "バックアップコードについて",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。", "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、バックアップコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "いつでも新しいバックアップコード再生成できます。これにより、既存のすべてのコードが無効になります", "You can regenerate new backup codes at any time. This will invalidate all existing codes.": "新しいバックアップコードはいつでも再生成できます。既存のコードはすべて無効になります",
"Confirm password": "パスワードを確認", "Confirm password": "パスワードを確認",
"Generate new backup codes": "新しいバックアップコードを生成", "Generate new backup codes": "新しいバックアップコードを生成",
"Save your new backup codes": "新しいバックアップコードを保存", "Save your new backup codes": "新しいバックアップコードを保存",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効です。", "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効になりました",
"Your new backup codes": "新しいバックアップコード", "Your new backup codes": "新しいバックアップコード",
"I've saved my backup codes": "バックアップコードを保存しました", "I've saved my backup codes": "バックアップコードを保存しました",
"Failed to setup MFA": "MFAの設定に失敗しました", "Failed to setup MFA": "MFAの設定に失敗しました",
@@ -449,51 +455,51 @@
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:", "Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください", "2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
"Verify and enable": "確認と有効化", "Verify and enable": "確認と有効化",
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。再試行してください", "Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。もう一度お試しください",
"Backup": "バックアップ", "Backup": "バックアップ",
"Save codes": "コードを保存", "Save codes": "コードを保存",
"Save your backup codes": "バックアップコードを保存", "Save your backup codes": "バックアップコードを保存",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。", "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、これらのコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
"Print": "印刷", "Print": "印刷",
"Two-factor authentication has been set up. Please log in again.": "二要素認証設定されました。再度ログインしてください", "Two-factor authentication has been set up. Please log in again.": "二要素認証設定ました。再度ログインしてください",
"Two-Factor authentication required": "二要素認証が必要です", "Two-Factor authentication required": "二要素認証が必要です",
"Your workspace requires two-factor authentication for all users": "ワークスペースではすべてのユーザーに二要素認証が必要です", "Your workspace requires two-factor authentication for all users": "このワークスペースではすべてのユーザーに二要素認証が必要です",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースへのアクセスを続けるには二要素認証を設定する必要があります。これにより、アカウントに追加のセキュリティ層が追加されます", "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースアクセスるには二要素認証を設定してください。アカウントのセキュリティが強化されます",
"Set up two-factor authentication": "二要素認証を設定", "Set up two-factor authentication": "二要素認証を設定",
"Cancel and logout": "キャンセルしてログアウト", "Cancel and logout": "キャンセルしてログアウト",
"Your workspace requires two-factor authentication. Please set it up to continue.": "ワークスペースでは二要素認証が必要です。続行するには設定してください", "Your workspace requires two-factor authentication. Please set it up to continue.": "このワークスペースでは二要素認証が必要です。続行するには設定してください",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "これにより、認証アプリからの確認コードが必要となり、アカウントに追加のセキュリティ層が追加されます", "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "認証アプリからの確認コードアカウントのセキュリティが強化されます",
"Password is required": "パスワードが必要です", "Password is required": "パスワードが必要です",
"Password must be at least 8 characters": "パスワードは8文字以上必要です", "Password must be at least 8 characters": "パスワードは8文字以上必要です",
"Please enter a 6-digit code": "6桁のコードを入力してください", "Please enter a 6-digit code": "6桁のコードを入力してください",
"Code must be exactly 6 digits": "コードは正確に6桁である必要があります", "Code must be exactly 6 digits": "コードは6桁で入力してください",
"Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください", "Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください",
"Need help authenticating?": "認証に関するヘルプが必要ですか?", "Need help authenticating?": "認証に関するヘルプが必要ですか?",
"MFA QR Code": "MFA QRコード", "MFA QR Code": "MFA QRコード",
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントが正常に作成されました。二要素認証を設定するためにログインしてください", "Account created successfully. Please log in to set up two-factor authentication.": "アカウントを作成しました。二要素認証を設定するためにログインしてください",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットが成功しました。新しいパスワードでログインし二要素認証を完了してください", "Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインし二要素認証を完了してください",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットが成功しました。二要素認証を設定するために新しいパスワードでログインしてください", "Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインして二要素認証を設定してください",
"Password reset was successful. Please log in with your new password.": "パスワードリセットが成功しました。新しいパスワードでログインしてください", "Password reset was successful. Please log in with your new password.": "パスワードリセットしました。新しいパスワードでログインしてください",
"Two-factor authentication": "二要素認証", "Two-factor authentication": "二要素認証",
"Use authenticator app instead": "代わりに認証アプリを使用", "Use authenticator app instead": "代わりに認証アプリを使用",
"Verify backup code": "バックアップコードを確認", "Verify backup code": "バックアップコードを確認",
"Use backup code": "バックアップコードを使用", "Use backup code": "バックアップコードを使用",
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください", "Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
"Backup code": "バックアップコード", "Backup code": "バックアップコード",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。", "Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です",
"Verify": "確認", "Verify": "確認",
"Trash": "ごみ箱", "Trash": "ごみ箱",
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます", "Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
"Deleted": "削除", "Deleted": "削除",
"No pages in trash": "ごみ箱にページがありません", "No pages in trash": "ごみ箱にページがありません",
"Permanently delete page?": "ページを完全に削除しますか?", "Permanently delete page?": "ページを完全に削除しますか?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますか? この操作は元に戻せません", "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますかこの操作は取り消せません",
"Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?", "Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?",
"Move to trash": "ごみ箱に移動", "Move to trash": "ごみ箱に移動",
"Move this page to trash?": "このページをごみ箱に移動しますか?", "Move this page to trash?": "このページをごみ箱に移動しますか?",
"Restore page": "ページを復元", "Restore page": "ページを復元",
"Page moved to trash": "ページごみ箱に移動されました", "Page moved to trash": "ページごみ箱に移動ました",
"Page restored successfully": "ページが正常に復元されました", "Page restored successfully": "ページを復元しました",
"Deleted by": "削除者", "Deleted by": "削除者",
"Deleted at": "削除日時", "Deleted at": "削除日時",
"Preview": "プレビュー", "Preview": "プレビュー",
@@ -511,10 +517,10 @@
"Enterprise": "エンタープライズ", "Enterprise": "エンタープライズ",
"Download attachment": "添付ファイルをダウンロード", "Download attachment": "添付ファイルをダウンロード",
"Allowed email domains": "許可されたメールドメイン", "Allowed email domains": "許可されたメールドメイン",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインからのメールアドレスを持つユーザーのみSSOで登録できます", "Only users with email addresses from these domains can signup via SSO.": "これらのドメインのメールアドレスを持つユーザーのみSSO経由で登録できます",
"Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください", "Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
"Enforce two-factor authentication": "二要素認証を強制する", "Enforce two-factor authentication": "二要素認証を強制する",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "一度強制されると、すべてのメンバーはワークスペースにアクセスするために二要素認証を有効にする必要があります", "Once enforced, all members must enable two-factor authentication to access the workspace.": "有効にすると、すべてのメンバーが二要素認証を設定しないとワークスペースにアクセスできなくなります",
"Toggle MFA enforcement": "MFAの強制を切り替える", "Toggle MFA enforcement": "MFAの強制を切り替える",
"Display name": "表示名", "Display name": "表示名",
"Allow signup": "登録を許可する", "Allow signup": "登録を許可する",
@@ -532,10 +538,10 @@
"Upload image": "画像をアップロード", "Upload image": "画像をアップロード",
"Remove image": "画像を削除", "Remove image": "画像を削除",
"Failed to remove image": "画像の削除に失敗しました", "Failed to remove image": "画像の削除に失敗しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています", "Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
"Image removed successfully": "画像が正常に削除されました", "Image removed successfully": "画像を削除しました",
"API key": "APIキー", "API key": "APIキー",
"API key created successfully": "APIキーが正常に作成されました", "API key created successfully": "APIキーを作成しました",
"API keys": "APIキー", "API keys": "APIキー",
"API management": "API管理", "API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか", "Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
@@ -550,9 +556,9 @@
"No API keys found": "APIキーが見つかりません", "No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし", "No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする", "Revoke API key": "APIキーを無効にする",
"Revoked successfully": "正常に無効化されました", "Revoked successfully": "無効にしました",
"Select expiration date": "有効期限を選択してください", "Select expiration date": "有効期限を選択してください",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は元に戻せません。このAPIキーを使用しているアプリケーションは動作を停止します", "This action cannot be undone. Any applications using this API key will stop working.": "この操作は取り消せません。このAPIキーを使用しているアプリケーションは動作しなくなります",
"Update API key": "APIキーを更新", "Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理", "Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"AI settings": "AI設定", "AI settings": "AI設定",
@@ -562,7 +568,7 @@
"AI is thinking...": "AIが考え中...", "AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...", "Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)", "AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペースコンテンツ全体にわたって意味検索機能を提供します", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "AI検索を切り替え", "Toggle AI search": "AI検索を切り替え",
"Sources": "ソース", "Sources": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません", "Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.", "Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.", "Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
"Confirm": "확인", "Confirm": "확인",
"Copy as Markdown": "Markdown으로 복사",
"Copy link": "링크 복사", "Copy link": "링크 복사",
"Create": "생성", "Create": "생성",
"Create group": "팀 생성", "Create group": "팀 생성",
@@ -122,6 +123,8 @@
"page": "페이지", "page": "페이지",
"Page deleted successfully": "페이지 삭제 완료", "Page deleted successfully": "페이지 삭제 완료",
"Page history": "페이지 기록", "Page history": "페이지 기록",
"Select version": "버전 선택",
"Highlight changes": "변경 사항 강조",
"Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.", "Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.",
"Pages": "페이지", "Pages": "페이지",
"pages": "페이지", "pages": "페이지",
@@ -253,6 +256,7 @@
"Export failed:": "내보내기 실패:", "Export failed:": "내보내기 실패:",
"export error": "내보내기 오류", "export error": "내보내기 오류",
"Export page": "페이지 내보내기", "Export page": "페이지 내보내기",
"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}}을 초과했습니다",
@@ -328,6 +332,8 @@
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.", "Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.", "Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
"Upload any file from your device.": "기기에서 파일을 업로드하세요.", "Upload any file from your device.": "기기에서 파일을 업로드하세요.",
"Uploading {{name}}": "{{name}} 업로드 중",
"Uploading file": "파일 업로드 중",
"Table": "테이블", "Table": "테이블",
"Insert a table.": "테이블 삽입.", "Insert a table.": "테이블 삽입.",
"Insert collapsible block.": "접을 수 있는 블록 삽입.", "Insert collapsible block.": "접을 수 있는 블록 삽입.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.", "Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.", "Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
"Confirm": "Bevestig", "Confirm": "Bevestig",
"Copy as Markdown": "Kopiëren als Markdown",
"Copy link": "Link kopiëren", "Copy link": "Link kopiëren",
"Create": "Aanmaken", "Create": "Aanmaken",
"Create group": "Groep aanmaken", "Create group": "Groep aanmaken",
@@ -122,6 +123,8 @@
"page": "pagina", "page": "pagina",
"Page deleted successfully": "Pagina succesvol verwijderd", "Page deleted successfully": "Pagina succesvol verwijderd",
"Page history": "Pagina geschiedenis", "Page history": "Pagina geschiedenis",
"Select version": "Selecteer versie",
"Highlight changes": "Wijzigingen markeren",
"Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.", "Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.",
"Pages": "Pagina's", "Pages": "Pagina's",
"pages": "pagina's", "pages": "pagina's",
@@ -253,6 +256,7 @@
"Export failed:": "Exporteren mislukt:", "Export failed:": "Exporteren mislukt:",
"export error": "Exporteer fout", "export error": "Exporteer fout",
"Export page": "Exporteer pagina", "Export page": "Exporteer pagina",
"Export successful": "Export succesvol",
"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}}",
@@ -328,6 +332,8 @@
"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 file from your device.": "Upload een bestand vanaf uw apparaat.", "Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
"Uploading {{name}}": "Uploaden {{name}}",
"Uploading file": "Bestand uploaden",
"Table": "Tabel", "Table": "Tabel",
"Insert a table.": "Voeg een tabel in.", "Insert a table.": "Voeg een tabel in.",
"Insert collapsible block.": "Inklapbaar blok invoegen.", "Insert collapsible block.": "Inklapbaar blok invoegen.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Escolha o idioma da interface.", "Choose your preferred interface language.": "Escolha o idioma da interface.",
"Choose your preferred page width.": "Escolha a largura preferida da página.", "Choose your preferred page width.": "Escolha a largura preferida da página.",
"Confirm": "Confirmar", "Confirm": "Confirmar",
"Copy as Markdown": "Copiar como Markdown",
"Copy link": "Copiar link", "Copy link": "Copiar link",
"Create": "Criar", "Create": "Criar",
"Create group": "Criar grupo", "Create group": "Criar grupo",
@@ -122,6 +123,8 @@
"page": "página", "page": "página",
"Page deleted successfully": "Página excluída com sucesso", "Page deleted successfully": "Página excluída com sucesso",
"Page history": "Histórico da página", "Page history": "Histórico da página",
"Select version": "Selecionar versão",
"Highlight changes": "Destacar alterações",
"Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.", "Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.",
"Pages": "Páginas", "Pages": "Páginas",
"pages": "páginas", "pages": "páginas",
@@ -253,6 +256,7 @@
"Export failed:": "Falha ao exportar:", "Export failed:": "Falha ao exportar:",
"export error": "erro de exportação", "export error": "erro de exportação",
"Export page": "Exportar página", "Export page": "Exportar página",
"Export successful": "Exportação bem-sucedida",
"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}}",
@@ -328,6 +332,8 @@
"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 file from your device.": "Envie qualquer arquivo do seu dispositivo.", "Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
"Uploading {{name}}": "Enviando {{name}}",
"Uploading file": "Enviando arquivo",
"Table": "Tabela", "Table": "Tabela",
"Insert a table.": "Insira uma tabela.", "Insert a table.": "Insira uma tabela.",
"Insert collapsible block.": "Insira um bloco colapsável.", "Insert collapsible block.": "Insira um bloco colapsável.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.", "Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.", "Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
"Confirm": "Подтвердить", "Confirm": "Подтвердить",
"Copy as Markdown": "Копировать как Markdown",
"Copy link": "Копировать ссылку", "Copy link": "Копировать ссылку",
"Create": "Создать", "Create": "Создать",
"Create group": "Создать группу", "Create group": "Создать группу",
@@ -122,6 +123,8 @@
"page": "страница", "page": "страница",
"Page deleted successfully": "Страница успешно удалена", "Page deleted successfully": "Страница успешно удалена",
"Page history": "История страницы", "Page history": "История страницы",
"Select version": "Выбрать версию",
"Highlight changes": "Выделить изменения",
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.", "Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
"Pages": "Страницы", "Pages": "Страницы",
"pages": "страницы", "pages": "страницы",
@@ -253,6 +256,7 @@
"Export failed:": "Экспортирование не удалось:", "Export failed:": "Экспортирование не удалось:",
"export error": "ошибка экспорта", "export error": "ошибка экспорта",
"Export page": "Экспорт страницы", "Export page": "Экспорт страницы",
"Export successful": "Экспорт выполнен успешно",
"Export space": "Экспорт пространства", "Export space": "Экспорт пространства",
"Export {{type}}": "Экспорт {{type}}", "Export {{type}}": "Экспорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
@@ -328,6 +332,8 @@
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.", "Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.", "Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.", "Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
"Uploading {{name}}": "Загрузка {{name}}",
"Uploading file": "Загрузка файла",
"Table": "Таблица", "Table": "Таблица",
"Insert a table.": "Вставить таблицу.", "Insert a table.": "Вставить таблицу.",
"Insert collapsible block.": "Вставить сворачиваемый блок.", "Insert collapsible block.": "Вставить сворачиваемый блок.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.", "Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.", "Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
"Confirm": "Підтвердити", "Confirm": "Підтвердити",
"Copy as Markdown": "Скопіювати як Markdown",
"Copy link": "Копіювати посилання", "Copy link": "Копіювати посилання",
"Create": "Створити", "Create": "Створити",
"Create group": "Створити групу", "Create group": "Створити групу",
@@ -122,6 +123,8 @@
"page": "сторінка", "page": "сторінка",
"Page deleted successfully": "Сторінку успішно видалено", "Page deleted successfully": "Сторінку успішно видалено",
"Page history": "Історія сторінки", "Page history": "Історія сторінки",
"Select version": "Вибрати версію",
"Highlight changes": "Підсвітити зміни",
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.", "Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
"Pages": "Сторінки", "Pages": "Сторінки",
"pages": "сторінки", "pages": "сторінки",
@@ -253,6 +256,7 @@
"Export failed:": "Експортування не вдалося:", "Export failed:": "Експортування не вдалося:",
"export error": "помилка експорту", "export error": "помилка експорту",
"Export page": "Експорт сторінки", "Export page": "Експорт сторінки",
"Export successful": "Експорт виконано успішно",
"Export space": "Експорт простору", "Export space": "Експорт простору",
"Export {{type}}": "Експорт {{type}}", "Export {{type}}": "Експорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
@@ -328,6 +332,8 @@
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.", "Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.", "Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.", "Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
"Uploading {{name}}": "Завантаження {{name}}",
"Uploading file": "Завантаження файлу",
"Table": "Таблиця", "Table": "Таблиця",
"Insert a table.": "Вставити таблицю.", "Insert a table.": "Вставити таблицю.",
"Insert collapsible block.": "Вставити блок, що згортається.", "Insert collapsible block.": "Вставити блок, що згортається.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "选择您喜欢的界面语言。", "Choose your preferred interface language.": "选择您喜欢的界面语言。",
"Choose your preferred page width.": "选择您喜欢的页面宽度。", "Choose your preferred page width.": "选择您喜欢的页面宽度。",
"Confirm": "确认", "Confirm": "确认",
"Copy as Markdown": "复制为Markdown",
"Copy link": "复制链接", "Copy link": "复制链接",
"Create": "创建", "Create": "创建",
"Create group": "创建群组", "Create group": "创建群组",
@@ -122,6 +123,8 @@
"page": "个页面", "page": "个页面",
"Page deleted successfully": "页面已成功删除", "Page deleted successfully": "页面已成功删除",
"Page history": "页面历史", "Page history": "页面历史",
"Select version": "选择版本",
"Highlight changes": "突出显示更改",
"Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。", "Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。",
"Pages": "页面", "Pages": "页面",
"pages": "个页面", "pages": "个页面",
@@ -253,6 +256,7 @@
"Export failed:": "导出失败:", "Export failed:": "导出失败:",
"export error": "导出出错", "export error": "导出出错",
"Export page": "导出页面", "Export page": "导出页面",
"Export successful": "导出成功",
"Export space": "导出空间", "Export space": "导出空间",
"Export {{type}}": "导出为 {{type}}", "Export {{type}}": "导出为 {{type}}",
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制", "File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
@@ -328,6 +332,8 @@
"Upload any image from your device.": "从设备上传任何图像", "Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频", "Upload any video from your device.": "从设备上传任何视频",
"Upload any file from your device.": "从设备上传任何文件", "Upload any file from your device.": "从设备上传任何文件",
"Uploading {{name}}": "正在上传{{name}}",
"Uploading file": "正在上传文件",
"Table": "表格", "Table": "表格",
"Insert a table.": "插入一个表格", "Insert a table.": "插入一个表格",
"Insert collapsible block.": "插入一个折叠块", "Insert collapsible block.": "插入一个折叠块",
@@ -30,9 +30,11 @@ export default function ExportModal({
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown); const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false); const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false); const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const [isExporting, setIsExporting] = useState<boolean>(false);
const { t } = useTranslation(); const { t } = useTranslation();
const handleExport = async () => { const handleExport = async () => {
setIsExporting(true);
try { try {
if (type === "page") { if (type === "page") {
await exportPage({ await exportPage({
@@ -45,6 +47,9 @@ export default function ExportModal({
if (type === "space") { if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments }); await exportSpace({ spaceId: id, format, includeAttachments });
} }
notifications.show({
message: t("Export successful"),
});
onClose(); onClose();
} catch (err) { } catch (err) {
notifications.show({ notifications.show({
@@ -52,6 +57,8 @@ export default function ExportModal({
color: "red", color: "red",
}); });
console.error("export error", err); console.error("export error", err);
} finally {
setIsExporting(false);
} }
}; };
@@ -136,7 +143,7 @@ export default function ExportModal({
<Button onClick={onClose} variant="default"> <Button onClick={onClose} variant="default">
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={handleExport}>{t("Export")}</Button> <Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
</Group> </Group>
</Modal.Body> </Modal.Body>
</Modal.Content> </Modal.Content>
@@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface PagePaginationProps { export interface PagePaginationProps {
currentPage: number;
hasPrevPage: boolean; hasPrevPage: boolean;
hasNextPage: boolean; hasNextPage: boolean;
onPageChange: (newPage: number) => void; onPrev: () => void;
onNext: () => void;
} }
export default function Paginate({ export default function Paginate({
currentPage,
hasPrevPage, hasPrevPage,
hasNextPage, hasNextPage,
onPageChange, onPrev,
onNext,
}: PagePaginationProps) { }: PagePaginationProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -25,7 +25,7 @@ export default function Paginate({
<Button <Button
variant="default" variant="default"
size="compact-sm" size="compact-sm"
onClick={() => onPageChange(currentPage - 1)} onClick={onPrev}
disabled={!hasPrevPage} disabled={!hasPrevPage}
> >
{t("Prev")} {t("Prev")}
@@ -34,7 +34,7 @@ export default function Paginate({
<Button <Button
variant="default" variant="default"
size="compact-sm" size="compact-sm"
onClick={() => onPageChange(currentPage + 1)} onClick={onNext}
disabled={!hasNextPage} disabled={!hasNextPage}
> >
{t("Next")} {t("Next")}
@@ -5,26 +5,27 @@ import {
Badge, Badge,
Table, Table,
ActionIcon, ActionIcon,
} from '@mantine/core'; } from "@mantine/core";
import {Link} from 'react-router-dom'; import { Link } from "react-router-dom";
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx'; import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from '@/features/page/page.utils.ts'; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from '@/lib/time.ts'; import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts'; import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription } from '@tabler/icons-react'; import { IconFileDescription } from "@tabler/icons-react";
import { getSpaceUrl } from '@/lib/config.ts'; import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
interface Props { interface Props {
spaceId?: string; spaceId?: string;
} }
export default function RecentChanges({spaceId}: Props) { export default function RecentChanges({ spaceId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId); const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
if (isLoading) { if (isLoading) {
return <PageListSkeleton/>; return <PageListSkeleton />;
} }
if (isError) { if (isError) {
@@ -44,8 +45,8 @@ export default function RecentChanges({spaceId}: Props) {
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
{page.icon || ( {page.icon || (
<ActionIcon variant='transparent' color='gray' size={18}> <ActionIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18}/> <IconFileDescription size={18} />
</ActionIcon> </ActionIcon>
)} )}
@@ -58,18 +59,23 @@ export default function RecentChanges({spaceId}: Props) {
{!spaceId && ( {!spaceId && (
<Table.Td> <Table.Td>
<Badge <Badge
color="blue" color={getInitialsColor(page?.space.name)}
variant="light" variant="light"
component={Link} component={Link}
to={getSpaceUrl(page?.space.slug)} to={getSpaceUrl(page?.space.slug)}
style={{cursor: 'pointer'}} style={{ cursor: "pointer" }}
> >
{page?.space.name} {page?.space.name}
</Badge> </Badge>
</Table.Td> </Table.Td>
)} )}
<Table.Td> <Table.Td>
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}> <Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
{formattedDate(page.updatedAt)} {formattedDate(page.updatedAt)}
</Text> </Text>
</Table.Td> </Table.Td>
@@ -13,7 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key"; import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams; const params: QueryParams = { limit: 100, query: "" };
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["workspaceMembers", params], queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params), queryFn: () => getWorkspaceMembers(params),
@@ -22,15 +22,15 @@ export const prefetchWorkspaceMembers = () => {
export const prefetchSpaces = () => { export const prefetchSpaces = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["spaces", { page: 1 }], queryKey: ["spaces", {}],
queryFn: () => getSpaces({ page: 1 }), queryFn: () => getSpaces({}),
}); });
}; };
export const prefetchGroups = () => { export const prefetchGroups = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["groups", { page: 1 }], queryKey: ["groups", {}],
queryFn: () => getGroups({ page: 1 }), queryFn: () => getGroups({}),
}); });
}; };
@@ -62,21 +62,21 @@ export const prefetchSsoProviders = () => {
export const prefetchShares = () => { export const prefetchShares = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["share-list", { page: 1 }], queryKey: ["share-list", {}],
queryFn: () => getShares({ page: 1, limit: 100 }), queryFn: () => getShares({}),
}); });
}; };
export const prefetchApiKeys = () => { export const prefetchApiKeys = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }], queryKey: ["api-key-list", {}],
queryFn: () => getApiKeys({ page: 1 }), queryFn: () => getApiKeys({}),
}); });
}; };
export const prefetchApiKeyManagement = () => { export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }], queryKey: ["api-key-list", { adminView: true }],
queryFn: () => getApiKeys({ page: 1, adminView: true }), queryFn: () => getApiKeys({ adminView: true }),
}); });
}; };
@@ -0,0 +1,49 @@
import { useRef, useState, ReactNode } from "react";
import { Text, TextProps, Tooltip } from "@mantine/core";
type AutoTooltipTextProps = TextProps & {
children: ReactNode;
tooltipLabel?: string;
tooltipProps?: Omit<
React.ComponentProps<typeof Tooltip>,
"children" | "label"
>;
};
export function AutoTooltipText({
children,
tooltipLabel,
tooltipProps,
...textProps
}: AutoTooltipTextProps) {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const handleMouseEnter = () => {
const element = textRef.current;
if (element) {
setIsTruncated(element.scrollWidth > element.clientWidth);
}
};
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
return (
<Tooltip
label={label}
disabled={!isTruncated || !label}
multiline
withArrow
{...tooltipProps}
>
<Text
ref={textRef}
truncate
onMouseEnter={handleMouseEnter}
{...textProps}
>
{children}
</Text>
</Tooltip>
);
}
@@ -10,19 +10,19 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal"; import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal"; import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts"; import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key"; import { IApiKey } from "@/ee/api-key";
export default function UserApiKeys() { export default function UserApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch(); const { cursor, goNext, goPrev } = useCursorPaginate();
const [createModalOpened, setCreateModalOpened] = useState(false); const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null); const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false); const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false); const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null); const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page }); const { data, isLoading } = useGetApiKeysQuery({ cursor });
const handleCreateSuccess = (response: IApiKey) => { const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response); setCreatedApiKey(response);
@@ -65,10 +65,10 @@ export default function UserApiKeys() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
@@ -10,20 +10,20 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal"; import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal"; import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts"; 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';
export default function WorkspaceApiKeys() { export default function WorkspaceApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch(); const { cursor, goNext, goPrev } = useCursorPaginate();
const [createModalOpened, setCreateModalOpened] = useState(false); const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null); const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false); const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false); const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null); const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true }); const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true });
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
if (!isAdmin) { if (!isAdmin) {
@@ -76,10 +76,10 @@ export default function WorkspaceApiKeys() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
@@ -0,0 +1,12 @@
import { isCloud } from "@/lib/config";
import useLicense from "@/ee/hooks/use-license";
import usePlan from "@/ee/hooks/use-plan";
const useEnterpriseAccess = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
};
export default useEnterpriseAccess;
@@ -0,0 +1,88 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
export default function DisablePublicSharing() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{t("Prevent members from sharing pages publicly.")}
</Text>
</div>
<DisablePublicSharingToggle />
</Group>
);
}
function DisablePublicSharingToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.sharing?.disabled === true,
);
const hasAccess = useEnterpriseAccess();
const applyChange = async (value: boolean) => {
try {
const updatedWorkspace = await updateWorkspace({
disablePublicSharing: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
modals.openConfirmModal({
title: value ? t("Disable public sharing") : t("Enable public sharing"),
children: (
<Text size="sm">
{value
? t(
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
)
: t(
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
)}
</Text>
),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
confirmProps: value ? { color: "red" } : {},
onConfirm: () => applyChange(value),
});
};
return (
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle public sharing")}
/>
</Tooltip>
);
}
@@ -10,23 +10,18 @@ export default function EnforceMfa() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <Group justify="space-between" wrap="nowrap" gap="xl">
<Title order={4} my="sm"> <div>
MFA <Text size="md">{t("Enforce two-factor authentication")}</Text>
</Title> <Text size="sm" c="dimmed">
<Group justify="space-between" wrap="nowrap" gap="xl"> {t(
<div> "Once enforced, all members must enable two-factor authentication to access the workspace.",
<Text size="md">{t("Enforce two-factor authentication")}</Text> )}
<Text size="sm" c="dimmed"> </Text>
{t( </div>
"Once enforced, all members must enable two-factor authentication to access the workspace.",
)}
</Text>
</div>
<EnforceMfaToggle /> <EnforceMfaToggle />
</Group> </Group>
</>
); );
} }
@@ -0,0 +1,84 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
type SpacePublicSharingToggleProps = {
space: ISpace;
};
export default function SpacePublicSharingToggle({
space,
}: SpacePublicSharingToggleProps) {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
const [checked, setChecked] = useState(
space.settings?.sharing?.disabled === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const applyChange = async (value: boolean) => {
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
disablePublicSharing: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
modals.openConfirmModal({
title: value ? t("Disable public sharing") : t("Enable public sharing"),
children: (
<Text size="sm">
{value
? t(
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
)
: t(
"Are you sure you want to enable public sharing for this space?",
)}
</Text>
),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
confirmProps: value ? { color: "red" } : {},
onConfirm: () => applyChange(value),
});
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{workspaceDisabled
? t("Public sharing is disabled at the workspace level")
: t("Prevent pages in this space from being shared publicly.")}
</Text>
</div>
<Tooltip
label={t("Public sharing is disabled at the workspace level")}
disabled={!workspaceDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={workspaceDisabled}
aria-label={t("Toggle space public sharing")}
/>
</Tooltip>
</Group>
);
}
@@ -43,7 +43,7 @@ export default function SsoProviderList() {
return null; return null;
} }
if (data?.length === 0) { if (data?.items.length === 0) {
return <Text c="dimmed">{t("No SSO providers found.")}</Text>; return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
} }
@@ -81,7 +81,7 @@ export default function SsoProviderList() {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{data {data?.items
.sort((a, b) => { .sort((a, b) => {
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled); const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
if (enabledDiff !== 0) return enabledDiff; if (enabledDiff !== 0) return enabledDiff;
@@ -104,7 +104,11 @@ export default function SsoProviderList() {
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}> <Badge
color={"gray"}
variant="light"
style={{ whiteSpace: "nowrap" }}
>
{provider.type.toUpperCase()} {provider.type.toUpperCase()}
</Badge> </Badge>
</Table.Td> </Table.Td>
@@ -134,41 +138,41 @@ export default function SsoProviderList() {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
onClick={() => handleEdit(provider)} onClick={() => handleEdit(provider)}
> >
<IconPencil size={16} /> <IconPencil size={16} />
</ActionIcon> </ActionIcon>
<Menu <Menu
transitionProps={{ transition: "pop" }} transitionProps={{ transition: "pop" }}
withArrow withArrow
position="bottom-end" position="bottom-end"
withinPortal withinPortal
> >
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray"> <ActionIcon variant="subtle" color="gray">
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
onClick={() => handleEdit(provider)} onClick={() => handleEdit(provider)}
leftSection={<IconPencil size={16} />} leftSection={<IconPencil size={16} />}
> >
{t("Edit")} {t("Edit")}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
onClick={() => openDeleteModal(provider.id)} onClick={() => openDeleteModal(provider.id)}
leftSection={<IconTrash size={16} />} leftSection={<IconTrash size={16} />}
color="red" color="red"
disabled={provider.type === SSO_PROVIDER.GOOGLE} disabled={provider.type === SSO_PROVIDER.GOOGLE}
> >
{t("Delete")} {t("Delete")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
+26 -10
View File
@@ -9,15 +9,16 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"
import EnforceSso from "@/ee/security/components/enforce-sso.tsx"; import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import usePlan from "@/ee/hooks/use-plan.tsx";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense(); const hasEnterpriseAccess = useEnterpriseAccess();
const { isBusiness } = usePlan(); const isCloudEE = useIsCloudEE();
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -30,26 +31,41 @@ export default function Security() {
</Helmet> </Helmet>
<SettingsTitle title={t("Security")} /> <SettingsTitle title={t("Security")} />
<AllowedDomains />
<Divider my="lg" />
<EnforceMfa /> <EnforceMfa />
<Divider my="lg" /> <Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && (
<>
<DisablePublicSharing />
<Divider my="lg" />
</>
)}
<Title order={4} my="lg"> <Title order={4} my="lg">
Single sign-on (SSO) Single sign-on (SSO)
</Title> </Title>
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? ( {hasEnterpriseAccess && (
<> <>
<EnforceSso /> <EnforceSso />
<Divider my="lg" /> <Divider my="lg" />
</>
)}
{isCloudEE && (
<>
<AllowedDomains />
<Divider my="lg" />
</>
)}
{hasEnterpriseAccess && (
<>
<CreateSsoProvider /> <CreateSsoProvider />
<Divider size={0} my="lg" /> <Divider size={0} my="lg" />
</> </>
) : null} )}
<SsoProviderList /> <SsoProviderList />
</> </>
@@ -13,8 +13,9 @@ import {
} from "@/ee/security/services/security-service.ts"; } from "@/ee/security/services/security-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> { export function useGetSsoProviders(): UseQueryResult<IPagination<IAuthProvider>, Error> {
return useQuery({ return useQuery({
queryKey: ["sso-providers"], queryKey: ["sso-providers"],
queryFn: () => getSsoProviders(), queryFn: () => getSsoProviders(),
@@ -1,5 +1,6 @@
import api from "@/lib/api-client.ts"; import api from "@/lib/api-client.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export async function getSsoProviderById(data: { export async function getSsoProviderById(data: {
providerId: string; providerId: string;
@@ -8,8 +9,8 @@ export async function getSsoProviderById(data: {
return req.data; return req.data;
} }
export async function getSsoProviders(): Promise<IAuthProvider[]> { export async function getSsoProviders(): Promise<IPagination<IAuthProvider>> {
const req = await api.post<IAuthProvider[]>("/sso/providers"); const req = await api.post<IPagination<IAuthProvider>>("/sso/providers");
return req.data; return req.data;
} }
@@ -1,11 +1,13 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon } from "@mantine/core"; import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react"; import { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks"; import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib"; import { formatBytes } from "@/lib";
import { useTranslation } from "react-i18next";
export default function AttachmentView(props: NodeViewProps) { export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected } = props; const { node, selected } = props;
const { url, name, size } = node.attrs; const { url, name, size } = node.attrs;
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
@@ -20,26 +22,28 @@ export default function AttachmentView(props: NodeViewProps) {
wrap="nowrap" wrap="nowrap"
h={25} h={25}
> >
<Group justify="space-between" wrap="nowrap"> <Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
<IconPaperclip size={20} /> {url ? (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
<Loader size={20} style={{ flexShrink: 0 }} />
)}
<Text component="span" size="md" truncate="end"> <Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
{name} {url ? name : t("Uploading {{name}}", { name })}
</Text> </Text>
<Text component="span" size="sm" c="dimmed" inline> <Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{formatBytes(size)} {formatBytes(size)}
</Text> </Text>
</Group> </Group>
{selected || hovered ? ( {url && (selected || hovered) && (
<a href={getFileUrl(url)} target="_blank"> <a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file"> <ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} /> <IconDownload size={18} />
</ActionIcon> </ActionIcon>
</a> </a>
) : (
""
)} )}
</Group> </Group>
</Paper> </Paper>
@@ -1,10 +1,6 @@
import { import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
BubbleMenu, import { isNodeSelection, useEditorState } from "@tiptap/react";
BubbleMenuProps, import type { Editor } from "@tiptap/react";
isNodeSelection,
useEditor,
useEditorState,
} from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from "react";
import { import {
IconBold, IconBold,
@@ -38,7 +34,7 @@ export interface BubbleMenuItem {
} }
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & { type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
}; };
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => { export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
@@ -133,14 +129,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
} }
return isTextSelected(editor); return isTextSelected(editor);
}, },
tippyOptions: { options: {
moveTransition: "transform 0.15s ease-out", placement: "top",
onCreate: (instance) => { offset: 8,
instance.popper.firstChild?.addEventListener("blur", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
});
},
onHide: () => { onHide: () => {
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
@@ -156,7 +147,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
return ( return (
<BubbleMenu {...bubbleMenuProps}> <BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
<div className={classes.bubbleMenu}> <div className={classes.bubbleMenu}>
<NodeSelector <NodeSelector
editor={props.editor} editor={props.editor}
@@ -1,9 +1,5 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
@@ -53,17 +49,26 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferenceClientRect = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout"; const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const setCalloutType = useCallback( const setCalloutType = useCallback(
@@ -112,14 +117,12 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`callout-menu`} pluginKey={`callout-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect, options={{
offset: [0, 10],
placement: "bottom", placement: "bottom",
zIndex: 99, // offset: 233, // // offset: [0, 10],
popperOptions: { // zIndex: 99,
modifiers: [{ name: "flip", enabled: false }], flip: false,
},
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -90,6 +90,7 @@ export default function CodeBlockView(props: NodeViewProps) {
node.textContent.length > 0 node.textContent.length > 0
} }
> >
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} /> <NodeViewContent as="code" className={`language-${language}`} />
</pre> </pre>
@@ -1,13 +1,12 @@
import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
export const handlePaste = ( export const handlePaste = (
view: EditorView, editor: Editor,
event: ClipboardEvent, event: ClipboardEvent,
pageId: string, pageId: string,
creatorId?: string, creatorId?: string,
@@ -18,7 +17,7 @@ export const handlePaste = (
// we have to do this validation here to allow the default link extension to takeover if needs be // we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault(); event.preventDefault();
const url = clipboardData.trim(); const url = clipboardData.trim();
const { from: pos, empty } = view.state.selection; const { from: pos, empty } = editor.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url); const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href); const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
@@ -34,19 +33,27 @@ export const handlePaste = (
return false; return false;
} }
const anchorId = match[6] ? match[6].split('#')[0] : undefined; const anchorId = match[6] ? match[6].split("#")[0] : undefined;
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url; const urlWithoutAnchor = anchorId
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId); ? url.substring(0, url.indexOf("#"))
: url;
createMentionAction(
urlWithoutAnchor,
editor.view,
pos,
creatorId,
anchorId,
);
return true; return true;
} }
if (event.clipboardData?.files.length) { if (event.clipboardData?.files.length) {
event.preventDefault(); event.preventDefault();
for (const file of event.clipboardData.files) { for (const file of event.clipboardData.files) {
const pos = view.state.selection.from; const pos = editor.state.selection.from;
uploadImageAction(file, view, pos, pageId); uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, view, pos, pageId); uploadVideoAction(file, editor, pos, pageId);
uploadAttachmentAction(file, view, pos, pageId); uploadAttachmentAction(file, editor, pos, pageId);
} }
return true; return true;
} }
@@ -54,7 +61,7 @@ export const handlePaste = (
}; };
export const handleFileDrop = ( export const handleFileDrop = (
view: EditorView, editor: Editor,
event: DragEvent, event: DragEvent,
moved: boolean, moved: boolean,
pageId: string, pageId: string,
@@ -63,14 +70,14 @@ export const handleFileDrop = (
event.preventDefault(); event.preventDefault();
for (const file of event.dataTransfer.files) { for (const file of event.dataTransfer.files) {
const coordinates = view.posAtCoords({ const coordinates = editor.view.posAtCoords({
left: event.clientX, left: event.clientX,
top: event.clientY, top: event.clientY,
}); });
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
} }
return true; return true;
} }
@@ -1,11 +1,6 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -40,17 +35,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferenceClientRect = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio"; const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const onWidthChange = useCallback(
@@ -65,15 +69,11 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`drawio-menu`} pluginKey={`drawio-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect, options={{
offset: [0, 8], placement: "top",
zIndex: 99, offset: 8,
popperOptions: { flip: false,
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -66,6 +66,7 @@ export default function DrawioView(props: NodeViewProps) {
const fileName = "diagram.drawio.svg"; const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName); const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; let attachment: IAttachment = null;
@@ -1,16 +1,41 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list"; import EmojiList from "./emoji-list";
import tippy from "tippy.js";
import { init } from "emoji-mart"; import { init } from "emoji-mart";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderEmojiItems = () => { const renderEmojiItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let popup: any | null = null; let popup: HTMLDivElement | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const destroy = () => {
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
}
if (component) {
component.destroy();
component = null;
}
};
return { return {
onBeforeStart: (props: { onBeforeStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
}) => { }) => {
init({ init({
data: async () => (await import("@emoji-mart/data")).default, data: async () => (await import("@emoji-mart/data")).default,
@@ -25,51 +50,61 @@ const renderEmojiItems = () => {
return; return;
} }
// @ts-ignore getReferenceClientRect = props.clientRect;
popup = tippy("body", { popup = document.createElement("div");
getReferenceClientRect: props.clientRect, popup.style.zIndex = "9999";
appendTo: () => document.body, popup.style.position = "absolute";
content: component.element, popup.style.top = "0";
showOnCreate: true, popup.style.left = "0";
interactive: true, popup.appendChild(component.element);
trigger: "manual", document.body.appendChild(popup);
placement: "bottom",
const virtualElement = {
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect(0, 0, 0, 0);
},
};
cleanup = autoUpdate(virtualElement, popup, () => {
if (!popup) return;
computePosition(virtualElement, popup, {
placement: "bottom-start",
middleware: [offset(10), flip(), shift()],
}).then(({ x, y }) => {
if (!popup) return;
Object.assign(popup.style, {
transform: `translate(${x}px, ${y}px)`,
});
});
}); });
}, },
onStart: (props: { onStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
}) => { }) => {
component?.updateProps({...props, isLoading: false}); component?.updateProps({ ...props, isLoading: false });
if (!props.clientRect) { if (props.clientRect) {
return; getReferenceClientRect = props.clientRect;
} }
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
}) => { }) => {
component?.updateProps(props); component?.updateProps(props);
if (!props.clientRect) { if (props.clientRect) {
return; getReferenceClientRect = props.clientRect;
} }
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key === "Escape") {
popup?.[0].hide(); destroy();
component?.destroy()
return true; return true;
} }
@@ -78,13 +113,7 @@ const renderEmojiItems = () => {
return component?.ref?.onKeyDown(props); return component?.ref?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
if (popup && !popup[0]?.state.isDestroyed) { destroy();
popup[0]?.destroy();
}
if (component) {
component?.destroy();
}
}, },
}; };
}; };
@@ -1,11 +1,6 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -42,17 +37,26 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferenceClientRect = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw"; const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const onWidthChange = useCallback(
@@ -65,17 +69,13 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`excalidraw-menu}`} pluginKey={`excalidraw-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect, options={{
offset: [0, 8], placement: "top",
zIndex: 99, offset: 8,
popperOptions: { flip: false,
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -98,6 +98,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
const fileName = "diagram.excalidraw.svg"; const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; let attachment: IAttachment = null;
@@ -1,11 +1,6 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next";
export function ImageMenu({ editor }: EditorMenuProps) { export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image");
},
[editor],
);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -52,17 +37,37 @@ export function ImageMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferenceClientRect = useCallback(() => { const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image") && editor.getAttributes("image").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image"; const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const alignImageLeft = useCallback(() => { const alignImageLeft = useCallback(() => {
@@ -105,15 +110,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`image-menu`} pluginKey={`image-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect, options={{
offset: [0, 8], placement: "top",
zIndex: 99, offset: 8,
popperOptions: { flip: false,
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -0,0 +1,27 @@
.imageWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
@@ -1,30 +1,70 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Image, Loader, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { Image } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./image-view.module.css";
import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) { export default function ImageView(props: NodeViewProps) {
const { node, selected } = props; const { t } = useTranslation();
const { src, width, align, title } = node.attrs; const { editor, node, selected } = props;
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
if (align === "center") return "alignCenter"; if (align === "center") return "alignCenter";
return "alignCenter"; return "alignCenter";
}, [align]); }, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.imagePreviews =
editor.storage.shared.imagePreviews || {};
if (placeholder?.id) {
return editor.storage.shared.imagePreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<Image <div
radius="md" className={clsx(
fit="contain" selected && "ProseMirror-selectednode",
w={width} classes.imageWrapper,
src={getFileUrl(src)} alignClass,
alt={title} )}
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)} style={{
/> aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<Image
radius="md"
fit="contain"
src={previewSrc}
alt={placeholder?.name}
/>
<Loader size={20} pos="absolute" bottom={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
@@ -1,9 +1,10 @@
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx"; import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
import { Card } from "@mantine/core"; import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
export function LinkMenu({ editor, appendTo }: EditorMenuProps) { export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
@@ -59,18 +60,15 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`link-menu}`} pluginKey={`link-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ options={{
appendTo: () => { onHide: () => {
return appendTo?.current;
},
onHidden: () => {
setShowEdit(false); setShowEdit(false);
}, },
placement: "bottom", placement: "bottom",
offset: [0, 5], offset: 5,
zIndex: 101, // zIndex: 101,
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -106,6 +106,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
setRenderItems(items); setRenderItems(items);
// update editor storage // update editor storage
//@ts-ignore
props.editor.storage.mentionItems = items; props.editor.storage.mentionItems = items;
} }
}, [suggestion, isLoading]); }, [suggestion, isLoading]);
@@ -163,7 +164,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const enterHandler = () => { const enterHandler = () => {
if (!renderItems.length) return; if (!renderItems.length) return;
if (renderItems[selectedIndex].entityType !== "header") { if (renderItems[selectedIndex]?.entityType !== "header") {
selectItem(selectedIndex); selectItem(selectedIndex);
} }
}; };
@@ -203,7 +204,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
parentPageId: page.id || null, parentPageId: page.id || null,
title: title title: title
}; };
let createdPage: IPage; let createdPage: IPage;
try { try {
createdPage = await createPageMutation.mutateAsync(payload); createdPage = await createPageMutation.mutateAsync(payload);
@@ -1,5 +1,11 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import tippy from "tippy.js"; import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import MentionList from "@/features/editor/components/mention/mention-list.tsx"; import MentionList from "@/features/editor/components/mention/mention-list.tsx";
function getWhitespaceCount(query: string) { function getWhitespaceCount(query: string) {
@@ -9,16 +15,27 @@ function getWhitespaceCount(query: string) {
const mentionRenderItems = () => { const mentionRenderItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let popup: any | null = null; let activeClientRect: (() => DOMRect) | null = null;
let updatePositionCleanup: (() => void) | null = null;
const destroy = () => {
updatePositionCleanup?.();
updatePositionCleanup = null;
component?.destroy();
if (component?.element?.parentNode) {
component.element.parentNode.removeChild(component.element);
}
component = null;
};
return { return {
onStart: (props: { onStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
query: string; query: string;
}) => { }) => {
// query must not start with a whitespace // query must not start with a whitespace
if (props.query.charAt(0) === ' '){ if (props.query.charAt(0) === " ") {
return; return;
} }
@@ -37,75 +54,95 @@ const mentionRenderItems = () => {
return; return;
} }
// @ts-ignore activeClientRect = props.clientRect;
popup = tippy("body", {
getReferenceClientRect: props.clientRect, const { element } = component;
appendTo: () => document.body, document.body.appendChild(element);
content: component.element,
showOnCreate: true, updatePositionCleanup = autoUpdate(
interactive: true, {
trigger: "manual", getBoundingClientRect: () =>
placement: "bottom-start", activeClientRect ? activeClientRect() : new DOMRect(),
}); },
element,
() => {
if (!component?.element) return;
computePosition(
{
getBoundingClientRect: () => {
return activeClientRect ? activeClientRect() : new DOMRect();
},
},
element,
{
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
},
).then(({ x, y }) => {
Object.assign(element.style, {
left: `${x}px`,
top: `${y}px`,
position: "absolute",
zIndex: "9999",
});
});
},
);
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
query: string; query: string;
}) => { }) => {
// query must not start with a whitespace // query must not start with a whitespace
if (props.query.charAt(0) === ' '){ if (props.query.charAt(0) === " ") {
component?.destroy(); destroy();
return; return;
} }
// only update component if popup is not destroyed // only update component if popup is not destroyed
if (!popup?.[0].state.isDestroyed) { if (component) {
component?.updateProps(props); component.updateProps(props);
} }
if (!props || !props.clientRect) { if (!props || !props.clientRect) {
return; return;
} }
activeClientRect = props.clientRect;
const whitespaceCount = getWhitespaceCount(props.query); const whitespaceCount = getWhitespaceCount(props.query);
// destroy component if space is greater 3 without a match // destroy component if space is greater 3 without a match
if ( if (
whitespaceCount > 3 && whitespaceCount > 4 &&
props.editor.storage.mentionItems.length === 0 //@ts-ignore
props.editor.storage.mentionItems.length === 1
) { ) {
popup?.[0]?.destroy(); destroy();
component?.destroy(); return;
}
// fallback exit
if (whitespaceCount > 7) {
destroy();
return; return;
} }
popup &&
!popup?.[0].state.isDestroyed &&
popup?.[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key) if (props.event.key === "Escape") {
if ( destroy();
props.event.key === "Escape" || return true;
(props.event.key === "Enter" && !popup?.[0].state.isShown) }
) {
popup?.[0].destroy(); if (props.event.key === "Enter" && !component) {
component?.destroy(); destroy();
return false; return false;
} }
return (component?.ref as any)?.onKeyDown(props); return (component?.ref as any)?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
if (popup && !popup?.[0].state.isDestroyed) { destroy();
popup[0].destroy();
}
if (component) {
component.destroy();
}
}, },
}; };
}; };
@@ -1,19 +1,21 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Anchor, Text } from "@mantine/core"; import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import classes from "./mention.module.css"; import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs; const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const { spaceSlug } = useParams(); const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const navigate = useNavigate();
const { const {
data: page, data: page,
isLoading, isLoading,
@@ -23,6 +25,20 @@ export default function MentionView(props: NodeViewProps) {
const location = useLocation(); const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share"); const isShareRoute = location.pathname.startsWith("/share");
const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId;
const handleClick = (e: React.MouseEvent) => {
if (isSamePage && anchorId) {
e.preventDefault();
const element = document.querySelector(`[id="${anchorId}"]`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
navigate(`#${anchorId}`, { replace: true });
}
}
};
const shareSlugUrl = buildSharedPageUrl({ const shareSlugUrl = buildSharedPageUrl({
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
@@ -45,6 +61,7 @@ export default function MentionView(props: NodeViewProps) {
to={ to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId) isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
} }
onClick={handleClick}
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
> >
@@ -73,6 +73,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
if (!editor) return; if (!editor) return;
const { results, resultIndex } = editor.storage.searchAndReplace; const { results, resultIndex } = editor.storage.searchAndReplace;
//TODO: check type error
//@ts-ignore
const position: Range = results[resultIndex]; const position: Range = results[resultIndex];
if (!position) return; if (!position) return;
@@ -161,6 +161,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -173,9 +174,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadImageAction(file, editor.view, pos, pageId);
uploadImageAction(file, editor, pos, pageId);
} }
} }
// Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -188,6 +193,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -195,12 +201,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "video/*"; input.accept = "video/*";
input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor.view, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
}
} }
// Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -213,6 +225,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -220,12 +233,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = ""; input.accept = "";
input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor.view, pos, pageId, true);
uploadAttachmentAction(file, editor, pos, pageId, true);
}
} }
// Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -1,10 +1,35 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import CommandList from "@/features/editor/components/slash-menu/command-list"; import CommandList from "@/features/editor/components/slash-menu/command-list";
import tippy from "tippy.js"; import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderItems = () => { const renderItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let popup: any | null = null; let popup: HTMLElement | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const updatePosition = () => {
if (!popup || !getReferenceClientRect) return;
// @ts-ignore
const rect = getReferenceClientRect();
computePosition({ getBoundingClientRect: () => rect }, popup, {
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
}).then(({ x, y }) => {
if (popup) {
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
}
});
};
return { return {
onStart: (props: { onStart: (props: {
@@ -21,15 +46,29 @@ const renderItems = () => {
} }
// @ts-ignore // @ts-ignore
popup = tippy("body", { getReferenceClientRect = props.clientRect;
getReferenceClientRect: props.clientRect,
appendTo: () => document.body, popup = document.createElement("div");
content: component.element, popup.style.zIndex = "9999";
showOnCreate: true, popup.style.position = "absolute";
interactive: true, popup.style.top = "0";
trigger: "manual", popup.style.left = "0";
placement: "bottom-start",
}); document.body.appendChild(popup);
popup.appendChild(component.element);
cleanup = autoUpdate(
// @ts-ignore
{
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect();
},
},
popup,
updatePosition
);
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
@@ -41,14 +80,15 @@ const renderItems = () => {
return; return;
} }
popup && // @ts-ignore
popup[0].setProps({ getReferenceClientRect = props.clientRect;
getReferenceClientRect: props.clientRect, updatePosition();
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key === "Escape") {
popup?.[0].hide(); if (popup) {
popup.style.display = "none";
}
return true; return true;
} }
@@ -57,12 +97,19 @@ const renderItems = () => {
return component?.ref?.onKeyDown(props); return component?.ref?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
if (popup && !popup[0].state.isDestroyed) { if (cleanup) {
popup[0].destroy(); cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
} }
if (component) { if (component) {
component.destroy(); component.destroy();
component = null;
} }
}, },
}; };
@@ -1,15 +1,11 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { posToDOMRect, findParentNode } from "@tiptap/react";
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { sticky } from "tippy.js";
interface SubpagesMenuProps { interface SubpagesMenuProps {
editor: Editor; editor: Editor;
@@ -33,7 +29,7 @@ export const SubpagesMenu = React.memo(
return editor.isActive("subpages"); return editor.isActive("subpages");
}, },
[editor], [editor]
); );
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
@@ -62,18 +58,8 @@ export const SubpagesMenu = React.memo(
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`subpages-menu}`} pluginKey={`subpages-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<Tooltip position="top" label={t("Delete")}> <Tooltip position="top" label={t("Delete")}>
@@ -89,7 +75,7 @@ export const SubpagesMenu = React.memo(
</Tooltip> </Tooltip>
</BaseBubbleMenu> </BaseBubbleMenu>
); );
}, }
); );
export default SubpagesMenu; export default SubpagesMenu;
@@ -19,6 +19,7 @@ export default function SubpagesView(props: NodeViewProps) {
const { spaceSlug, shareId } = useParams(); const { spaceSlug, shareId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
//@ts-ignore
const currentPageId = editor.storage.pageId; const currentPageId = editor.storage.pageId;
// Get subpages from shared tree if we're in a shared context // Get subpages from shared tree if we're in a shared context
@@ -1,6 +1,4 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -17,6 +15,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color"; import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment"; import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus";
export const TableCellMenu = React.memo( export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => { ({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -29,7 +28,7 @@ export const TableCellMenu = React.memo(
return isCellSelection(state.selection); return isCellSelection(state.selection);
}, },
[editor], [editor]
); );
const mergeCells = useCallback(() => { const mergeCells = useCallback(() => {
@@ -53,23 +52,27 @@ export const TableCellMenu = React.memo(
}, [editor]); }, [editor]);
return ( return (
<BaseBubbleMenu <BubbleMenu
editor={editor} editor={editor}
pluginKey="table-cell-menu" pluginKey="table-cell-menu"
updateDelay={0} updateDelay={0}
tippyOptions={{ appendTo={() => {
appendTo: () => { return appendTo?.current;
return appendTo?.current; }}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
offset: {
mainAxis: 15,
}, },
offset: [0, 15],
zIndex: 99,
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group> <ActionIcon.Group>
<TableBackgroundColor editor={editor} /> <TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} /> <TableTextAlignment editor={editor} />
<Tooltip position="top" label={t("Merge cells")}> <Tooltip position="top" label={t("Merge cells")}>
<ActionIcon <ActionIcon
onClick={mergeCells} onClick={mergeCells}
@@ -125,9 +128,9 @@ export const TableCellMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</BaseBubbleMenu> </BubbleMenu>
); );
}, }
); );
export default TableCellMenu; export default TableCellMenu;
@@ -1,11 +1,6 @@
import { import { posToDOMRect, findParentNode } from "@tiptap/react";
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -17,9 +12,12 @@ import {
IconColumnRemove, IconColumnRemove,
IconRowInsertBottom, IconRowInsertBottom,
IconRowInsertTop, IconRowInsertTop,
IconRowRemove, IconTableColumn, IconTableRow, IconRowRemove,
IconTableColumn,
IconTableRow,
IconTrashX, IconTrashX,
} from '@tabler/icons-react'; } from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus";
import { isCellSelection } from "@docmost/editor-ext"; import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -34,20 +32,28 @@ export const TableMenu = React.memo(
return editor.isActive("table") && !isCellSelection(state.selection); return editor.isActive("table") && !isCellSelection(state.selection);
}, },
[editor], [editor]
); );
const getReferenceClientRect = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table"; const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const rect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const rect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
}, [editor]); }, [editor]);
const toggleHeaderColumn = useCallback(() => { const toggleHeaderColumn = useCallback(() => {
@@ -87,42 +93,33 @@ export const TableMenu = React.memo(
}, [editor]); }, [editor]);
return ( return (
<BaseBubbleMenu <BubbleMenu
editor={editor} editor={editor}
pluginKey="table-menu" pluginKey="table-menu"
updateDelay={0} resizeDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect: getReferenceClientRect, ref={(element) => {
offset: [0, 15], element.style.zIndex = "99";
zIndex: 99, }}
popperOptions: { options={{
modifiers: [ placement: "top",
{ offset: {
name: "preventOverflow", mainAxis: 15,
enabled: true, },
options: { flip: {
altAxis: true, fallbackPlacements: ["top", "bottom"],
boundary: "clippingParents", padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
padding: 8, boundary: editor.options.element as HTMLElement,
}, },
}, shift: {
{ padding: 8 + 15,
name: "flip", crossAxis: true,
enabled: true,
options: {
boundary: editor.options.element,
fallbackPlacements: ["top", "bottom"],
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
},
},
],
}, },
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group> <ActionIcon.Group>
<Tooltip position="top" label={t("Add left column")} <Tooltip position="top" label={t("Add left column")}>
>
<ActionIcon <ActionIcon
onClick={addColumnLeft} onClick={addColumnLeft}
variant="default" variant="default"
@@ -188,8 +185,7 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Toggle header row")} <Tooltip position="top" label={t("Toggle header row")}>
>
<ActionIcon <ActionIcon
onClick={toggleHeaderRow} onClick={toggleHeaderRow}
variant="default" variant="default"
@@ -200,8 +196,7 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Toggle header column")} <Tooltip position="top" label={t("Toggle header column")}>
>
<ActionIcon <ActionIcon
onClick={toggleHeaderColumn} onClick={toggleHeaderColumn}
variant="default" variant="default"
@@ -224,9 +219,9 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</BaseBubbleMenu> </BubbleMenu>
); );
}, }
); );
export default TableMenu; export default TableMenu;
@@ -1,11 +1,6 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next";
export function VideoMenu({ editor }: EditorMenuProps) { export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video");
},
[editor],
);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -52,17 +37,37 @@ export function VideoMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferenceClientRect = useCallback(() => { const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video") && editor.getAttributes("video").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video"; const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const alignVideoLeft = useCallback(() => { const alignVideoLeft = useCallback(() => {
@@ -105,15 +110,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`video-menu`} pluginKey={`video-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect, options={{
offset: [0, 8], placement: "top",
zIndex: 99, offset: 8,
popperOptions: { flip: false,
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -0,0 +1,33 @@
.videoWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.video {
display: block;
width: 100%;
height: 100%;
border-radius: 8px;
}
@@ -1,29 +1,75 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./video-view.module.css";
import { useTranslation } from "react-i18next";
export default function VideoView(props: NodeViewProps) { export default function VideoView(props: NodeViewProps) {
const { node, selected } = props; const { t } = useTranslation();
const { src, width, align } = node.attrs; const { editor, node, selected } = props;
const { src, width, align, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
if (align === "center") return "alignCenter"; if (align === "center") return "alignCenter";
return "alignCenter"; return "alignCenter";
}, [align]); }, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.videoPreviews =
editor.storage.shared.videoPreviews || {};
if (placeholder?.id) {
return editor.storage.shared.videoPreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<video <div
preload="metadata" className={clsx(
width={width} selected && "ProseMirror-selectednode",
controls classes.videoWrapper,
src={getFileUrl(src)} alignClass,
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)} )}
style={{ display: "block" }} style={{
/> aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<video
className={classes.video}
preload="metadata"
controls
src={getFileUrl(src)}
/>
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<video
className={classes.video}
preload="metadata"
controls
src={previewSrc}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
@@ -1,11 +1,7 @@
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Placeholder } from "@tiptap/extension-placeholder";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { CharacterCount } from "@tiptap/extension-character-count"; import { TaskList, TaskItem } from "@tiptap/extension-list";
import { TaskList } from "@tiptap/extension-task-list"; import { Placeholder, CharacterCount } from "@tiptap/extensions";
import { ListKeymap } from "@tiptap/extension-list-keymap";
import { TaskItem } from "@tiptap/extension-task-item";
import { Underline } from "@tiptap/extension-underline";
import { Superscript } from "@tiptap/extension-superscript"; import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript"; import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography"; import { Typography } from "@tiptap/extension-typography";
@@ -15,7 +11,7 @@ import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube"; import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand from "@/features/editor/extensions/slash-command"; import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration"; import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor"; import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
import { HocuspocusProvider } from "@hocuspocus/provider"; import { HocuspocusProvider } from "@hocuspocus/provider";
import { import {
Comment, Comment,
@@ -41,11 +37,12 @@ import {
Embed, Embed,
SearchAndReplace, SearchAndReplace,
Mention, Mention,
Subpages,
TableDndExtension, TableDndExtension,
Subpages,
Heading, Heading,
Highlight, Highlight,
UniqueID, UniqueID,
SharedStorage,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -97,7 +94,9 @@ lowlight.register("scala", scala);
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
heading: false, heading: false,
history: false, undoRedo: false,
link: false,
trailingNode: false,
dropcursor: { dropcursor: {
width: 3, width: 3,
color: "#70CFF8", color: "#70CFF8",
@@ -109,6 +108,7 @@ export const mainExtensions = [
}, },
}, },
}), }),
SharedStorage,
Heading, Heading,
UniqueID.configure({ UniqueID.configure({
types: ["heading", "paragraph"], types: ["heading", "paragraph"],
@@ -134,8 +134,6 @@ export const mainExtensions = [
TaskItem.configure({ TaskItem.configure({
nested: true, nested: true,
}), }),
ListKeymap,
Underline,
LinkExtension.configure({ LinkExtension.configure({
openOnClick: false, openOnClick: false,
}), }),
@@ -170,6 +168,9 @@ export const mainExtensions = [
}, },
}).extend({ }).extend({
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView); return ReactNodeViewRenderer(MentionView);
}, },
}), }),
@@ -208,6 +209,7 @@ export const mainExtensions = [
}), }),
CustomCodeBlock.configure({ CustomCodeBlock.configure({
view: CodeBlockView, view: CodeBlockView,
//@ts-ignore
lowlight, lowlight,
HTMLAttributes: { HTMLAttributes: {
spellcheck: false, spellcheck: false,
@@ -246,7 +248,7 @@ export const mainExtensions = [
Escape: () => { Escape: () => {
const event = new CustomEvent("closeFindDialogFromEditor", {}); const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
return true; return false;
}, },
}; };
}, },
@@ -258,8 +260,9 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
export const collabExtensions: CollabExtensions = (provider, user) => [ export const collabExtensions: CollabExtensions = (provider, user) => [
Collaboration.configure({ Collaboration.configure({
document: provider.document, document: provider.document,
provider,
}), }),
CollaborationCursor.configure({ CollaborationCaret.configure({
provider, provider,
user: { user: {
name: user.name, name: user.name,
+102 -114
View File
@@ -1,13 +1,22 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { IndexeddbPersistence } from "y-indexeddb"; import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs"; import * as Y from "yjs";
import { import {
HocuspocusProvider, HocuspocusProvider,
onAuthenticationFailedParameters, onStatusParameters,
WebSocketStatus, WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import { import {
Editor,
EditorContent, EditorContent,
EditorProvider, EditorProvider,
useEditor, useEditor,
@@ -69,161 +78,140 @@ export default function PageEditor({
editable, editable,
content, content,
}: PageEditorProps) { }: PageEditorProps) {
const collaborationURL = useCollaborationUrl(); const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const editorCreated = useRef(false); const editorRef = useRef<Editor | null>(null);
useEffect(() => { useEffect(() => {
isComponentMounted.current = true; isComponentMounted.current = true;
}, []); }, []);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom); const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const ydocRef = useRef<Y.Doc | null>(null); const [isLocalSynced, setIsLocalSynced] = useState(false);
if (!ydocRef.current) { const [isRemoteSynced, setIsRemoteSynced] = useState(false);
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility(); const documentState = useDocumentVisibility();
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const userPageEditMode = const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]); () => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll }); const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId // Providers only created once per pageId
const providersRef = useRef<{ const providersRef = useRef<{
local: IndexeddbPersistence; local: IndexeddbPersistence;
remote: HocuspocusProvider; remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null); } | null>(null);
const [providersReady, setProvidersReady] = useState(false); const [providersReady, setProvidersReady] = useState(false);
const localProvider = providersRef.current?.local;
const remoteProvider = providersRef.current?.remote;
// Track when collaborative provider is ready and synced
const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
useEffect(() => { useEffect(() => {
if (!providersRef.current) { if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc); const local = new IndexeddbPersistence(documentName, ydoc);
local.on("synced", () => setLocalSynced(true)); const socket = new HocuspocusProviderWebsocket({
const remote = new HocuspocusProvider({
name: documentName,
url: collaborationURL, url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
}
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc, document: ydoc,
token: collabQuery?.token, token: collabQuery?.token,
connect: true, onAuthenticationFailed: onAuthenticationFailedHandler,
preserveConnection: false, onStatus: onStatusHandler,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => { onSynced: onSyncedHandler,
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
remote.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
remote.connect();
}, 100);
}
});
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
}); });
remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => { local.on("synced", onLocalSyncedHandler);
setYjsConnectionStatus(WebSocketStatus.Disconnected); providersRef.current = { socket, local, remote };
});
providersRef.current = { local, remote };
setProvidersReady(true); setProvidersReady(true);
} else { } else {
setProvidersReady(true); setProvidersReady(true);
} }
// Only destroy on final unmount // Only destroy on final unmount
return () => { return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy(); providersRef.current?.remote.destroy();
providersRef.current?.local.destroy(); providersRef.current?.local.destroy();
providersRef.current = null; providersRef.current = null;
}; };
}, [pageId]); }, [pageId]);
/*
useEffect(() => {
// Handle token updates by reconnecting with new token
if (providersRef.current?.remote && collabQuery?.token) {
const currentToken = providersRef.current.remote.configuration.token;
if (currentToken !== collabQuery.token) {
// Token has changed, need to reconnect with new token
providersRef.current.remote.disconnect();
providersRef.current.remote.configuration.token = collabQuery.token;
providersRef.current.remote.connect();
}
}
}, [collabQuery?.token]);
*/
// Only connect/disconnect on tab/idle, not destroy // Only connect/disconnect on tab/idle, not destroy
useEffect(() => { useEffect(() => {
if (!providersReady || !providersRef.current) return; if (!providersReady || !providersRef.current) return;
const remoteProvider = providersRef.current.remote; const socket = providersRef.current.socket;
if ( if (
isIdle && isIdle &&
documentState === "hidden" && documentState === "hidden" &&
remoteProvider.status === WebSocketStatus.Connected yjsConnectionStatus === WebSocketStatus.Connected
) { ) {
remoteProvider.disconnect(); socket.disconnect();
setIsCollabReady(false);
return; return;
} }
if ( if (
documentState === "visible" && documentState === "visible" &&
remoteProvider.status === WebSocketStatus.Disconnected yjsConnectionStatus === WebSocketStatus.Disconnected
) { ) {
resetIdle(); resetIdle();
remoteProvider.connect(); socket.connect();
setTimeout(() => setIsCollabReady(true), 500);
} }
}, [isIdle, documentState, providersReady, resetIdle]); }, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => { const extensions = useMemo(() => {
if (!remoteProvider || !currentUser?.user) return mainExtensions; if (!providersReady || !providersRef.current || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [ return [
...mainExtensions, ...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user), ...collabExtensions(remoteProvider, currentUser?.user),
]; ];
}, [remoteProvider, currentUser?.user]); }, [providersReady, currentUser?.user]);
const editor = useEditor( const editor = useEditor(
{ {
@@ -266,18 +254,30 @@ export default function PageEditor({
} }
}, },
}, },
handlePaste: (view, event, slice) => handlePaste: (_view, event) => {
handlePaste(view, event, pageId, currentUser?.user.id), if (!editorRef.current) return false;
handleDrop: (view, event, _slice, moved) =>
handleFileDrop(view, event, moved, pageId), return handlePaste(
editorRef.current,
event,
pageId,
currentUser?.user.id,
);
},
handleDrop: (_view, event, _slice, moved) => {
if (!editorRef.current) return false;
return handleFileDrop(editorRef.current, event, moved, pageId);
},
}, },
onCreate({ editor }) { onCreate({ editor }) {
if (editor) { if (editor) {
// @ts-ignore // @ts-ignore
setEditor(editor); setEditor(editor);
// @ts-ignore
editor.storage.pageId = pageId; editor.storage.pageId = pageId;
handleScrollTo(editor); handleScrollTo(editor);
editorCreated.current = true; editorRef.current = editor;
} }
}, },
onUpdate({ editor }) { onUpdate({ editor }) {
@@ -287,7 +287,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, remoteProvider], [pageId, editable, extensions],
); );
const editorIsEditable = useEditorState({ const editorIsEditable = useEditorState({
@@ -343,30 +343,17 @@ export default function PageEditor({
setAsideState({ tab: "", isAsideOpen: false }); setAsideState({ tab: "", isAsideOpen: false });
}, [pageId]); }, [pageId]);
useEffect(() => {
if (remoteProvider?.status === WebSocketStatus.Connecting) {
const timeout = setTimeout(() => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
}, 5000);
return () => clearTimeout(timeout);
}
}, [remoteProvider?.status]);
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => { useEffect(() => {
const collabReadyTimeout = setTimeout(() => { const timeout = setTimeout(() => {
if ( if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
!isCollabReady && setYjsConnectionStatus(WebSocketStatus.Disconnected);
isSynced &&
remoteProvider?.status === WebSocketStatus.Connected
) {
setIsCollabReady(true);
} }
}, 500); }, 7500);
return () => clearTimeout(collabReadyTimeout);
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]);
useEffect(() => { useEffect(() => {
// Only honor user default page edit mode preference and permissions // Only honor user default page edit mode preference and permissions
if (editor) { if (editor) {
@@ -388,12 +375,13 @@ export default function PageEditor({
useEffect(() => { useEffect(() => {
if ( if (
!hasConnectedOnceRef.current && !hasConnectedOnceRef.current &&
remoteProvider?.status === WebSocketStatus.Connected yjsConnectionStatus === WebSocketStatus.Connected &&
isSynced
) { ) {
hasConnectedOnceRef.current = true; hasConnectedOnceRef.current = true;
setShowStatic(false); setShowStatic(false);
} }
}, [remoteProvider?.status]); }, [yjsConnectionStatus, isSynced]);
if (showStatic) { if (showStatic) {
return ( return (
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { EditorProvider } from "@tiptap/react"; import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document"; import { Document } from "@tiptap/extension-document";
import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext"; import { Heading, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text"; import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -81,6 +81,7 @@ export default function ReadonlyPageEditor({
onCreate={({ editor }) => { onCreate={({ editor }) => {
if (editor) { if (editor) {
if (pageId) { if (pageId) {
// @ts-ignore
editor.storage.pageId = pageId; editor.storage.pageId = pageId;
} }
// @ts-ignore // @ts-ignore
@@ -1,5 +1,5 @@
/* Give a remote user a caret */ /* Give a remote user a caret */
.collaboration-cursor__caret { .collaboration-carets__caret {
border-left: 1px solid #0d0d0d; border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d; border-right: 1px solid #0d0d0d;
margin-left: -1px; margin-left: -1px;
@@ -10,7 +10,7 @@
} }
/* Render the username above the caret */ /* Render the username above the caret */
.collaboration-cursor__label { .collaboration-carets__label {
border-radius: 3px 3px 3px 0; border-radius: 3px 3px 3px 0;
color: #0d0d0d; color: #0d0d0d;
font-size: 0.75rem; font-size: 0.75rem;
@@ -8,7 +8,7 @@
} }
.mantine-AppShell-main { .mantine-AppShell-main {
padding-top: 0 !important; padding: 0 !important;
min-height: auto !important; min-height: auto !important;
} }
@@ -1,5 +1,7 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { IFileTask } from "@/features/file-task/types/file-task.types.ts"; import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IApiKey } from "@/ee/api-key";
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> { export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
const req = await api.post<IFileTask>("/file-tasks/info", { const req = await api.post<IFileTask>("/file-tasks/info", {
@@ -8,7 +10,10 @@ export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
return req.data; return req.data;
} }
export async function getFileTasks(): Promise<IFileTask[]> { export async function getFileTasks(
const req = await api.post<IFileTask[]>("/file-tasks"); params?: QueryParams,
): Promise<IPagination<IFileTask>> {
const req = await api.post("/file-tasks", { ...params });
return req.data; return req.data;
} }
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from 'mantine-form-zod-resolver'; import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({ const formSchema = z.object({
name: z.string().trim().min(2).max(50), name: z.string().trim().min(2).max(100),
description: z.string().max(500), description: z.string().max(500),
}); });
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from "mantine-form-zod-resolver"; import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(100),
description: z.string().max(500), description: z.string().max(500),
}); });
@@ -1,25 +1,25 @@
import { Table, Group, Text, Anchor } from "@mantine/core"; import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query"; import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { IGroup } from "@/features/group/types/group.types.ts"; import { IGroup } from "@/features/group/types/group.types.ts";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { getSpaces } from "@/features/space/services/space-service.ts";
import { getGroupMembers } from "@/features/group/services/group-service.ts"; import { getGroupMembers } from "@/features/group/services/group-service.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function GroupList() { export default function GroupList() {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(1); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGetGroupsQuery({ page }); const { data, isLoading } = useGetGroupsQuery({ cursor });
const prefetchGroupMembers = (groupId: string) => { const prefetchGroupMembers = (groupId: string) => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["groupMembers", groupId, { page: 1 }], queryKey: ["groupMembers", groupId, {}],
queryFn: () => getGroupMembers(groupId, { page: 1 }), queryFn: () => getGroupMembers(groupId, {}),
}); });
}; };
@@ -50,10 +50,10 @@ export default function GroupList() {
> >
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<IconGroupCircle /> <IconGroupCircle />
<div> <div style={{ minWidth: 0, overflow: "hidden" }}>
<Text fz="sm" fw={500} lineClamp={1}> <AutoTooltipText fz="sm" fw={500} lineClamp={1}>
{group.name} {group.name}
</Text> </AutoTooltipText>
<Text fz="xs" c="dimmed" lineClamp={2}> <Text fz="xs" c="dimmed" lineClamp={2}>
{group.description} {group.description}
</Text> </Text>
@@ -84,10 +84,10 @@ export default function GroupList() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
</> </>
@@ -4,7 +4,7 @@ import {
useRemoveGroupMemberMutation, useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query"; } from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import React, { useState } from "react"; import React from "react";
import { IconDots } from "@tabler/icons-react"; import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
@@ -12,12 +12,13 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
export default function GroupMembersList() { export default function GroupMembersList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { groupId } = useParams(); const { groupId } = useParams();
const [page, setPage] = useState(1); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGroupMembersQuery(groupId, { page }); const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
const removeGroupMember = useRemoveGroupMemberMutation(); const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
@@ -107,10 +108,10 @@ export default function GroupMembersList() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
</> </>
@@ -1,4 +1,9 @@
import { atom } from "jotai"; import { atom } from "jotai";
export const historyAtoms = atom<boolean>(false); export const historyAtoms = atom<boolean>(false);
export const activeHistoryIdAtom = atom<string>(''); export const activeHistoryIdAtom = atom<string>("");
export const activeHistoryPrevIdAtom = atom<string>("");
export const highlightChangesAtom = atom<boolean>(true);
export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom<DiffCounts | null>(null);
@@ -0,0 +1,69 @@
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
position: relative;
overflow: hidden;
}
.selectorWrapper {
padding: var(--mantine-spacing-sm);
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
flex-shrink: 0;
}
.selector {
width: 100%;
text-align: left;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
cursor: pointer;
&:hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
}
}
.dropdown {
max-height: rem(300px);
}
.option {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
&[data-combobox-selected] {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
&:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
.editorArea {
flex: 1;
min-height: 0;
}
.editorContent {
padding: var(--mantine-spacing-md);
padding-bottom: rem(60px);
}
.actionButtons {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
padding-bottom: rem(70px);
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
flex-shrink: 0;
}
.floatingBar {
position: fixed;
bottom: var(--mantine-spacing-md);
left: 50%;
transform: translateX(-50%);
z-index: 100;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
white-space: nowrap;
}
@@ -0,0 +1,79 @@
.history {
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
}
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
}
& :global(.history-diff-added) {
background: light-dark(#e1f3f2, #01654a) !important;
color: light-dark(#007b69, #cafff7) !important;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
& :global(.history-diff-deleted) {
text-decoration: line-through;
color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-4));
background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.1));
border-radius: rem(2px);
padding: 0 rem(2px);
}
& :global(.history-diff-node-added) {
outline: rem(2px) solid
light-dark(var(--mantine-color-teal-5), var(--mantine-color-teal-7));
outline-offset: rem(2px);
border-radius: rem(4px);
}
& :global(.history-diff-node-deleted) {
opacity: 0.5;
outline: rem(2px) dashed
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
outline-offset: rem(4px);
border-radius: rem(4px);
}
}
.active {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
}
.sidebar {
max-height: rem(700px);
width: rem(250px);
padding: var(--mantine-spacing-sm);
display: flex;
flex-direction: column;
border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.sidebarFlex {
display: flex;
}
.sidebarMain {
flex: 1;
}
.sidebarRightSection {
flex: 1;
padding: rem(16px) rem(40px);
}
@@ -1,33 +1,203 @@
import '@/features/editor/styles/index.css'; import "@/features/editor/styles/index.css";
import React, { useEffect } from 'react'; import { useEffect } from "react";
import { EditorContent, useEditor } from '@tiptap/react'; import { EditorContent, useEditor } from "@tiptap/react";
import { mainExtensions } from '@/features/editor/extensions/extensions'; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Title } from '@mantine/core'; import { Title } from "@mantine/core";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import historyClasses from "./css/history.module.css";
import { recreateTransform } from "@docmost/editor-ext";
import { DOMSerializer, Node } from "@tiptap/pm/model";
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { useAtom } from "jotai";
import {
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
export interface HistoryEditorProps { export interface HistoryEditorProps {
title: string; title: string;
content: any; content: any;
previousContent?: any;
} }
export function HistoryEditor({ title, content }: HistoryEditorProps) { export function HistoryEditor({
title,
content,
previousContent,
}: HistoryEditorProps) {
const [highlightChanges] = useAtom(highlightChangesAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
const editor = useEditor({ const editor = useEditor({
extensions: mainExtensions, extensions: mainExtensions,
editable: false, editable: false,
}); });
useEffect(() => { useEffect(() => {
if (editor && content) { if (!editor || !content) return;
let decorationSet = DecorationSet.empty;
let addedCount = 0;
let deletedCount = 0;
if (previousContent) {
try {
const schema = editor.schema;
const oldContent = Node.fromJSON(schema, previousContent);
const newContent = Node.fromJSON(schema, content);
const tr = recreateTransform(oldContent, newContent, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldContent).addSteps(
tr.doc,
tr.mapping.maps,
[],
);
const changes = simplifyChanges(changeSet.changes, newContent);
editor.commands.setContent(content);
const specialNodeTypes = new Set([
"image",
"attachment",
"video",
"excalidraw",
"drawio",
"mermaid",
"mathBlock",
"mathInline",
"table",
"details",
"callout",
]);
const decorations: Decoration[] = [];
let changeIndex = 0;
for (const change of changes) {
if (change.toB > change.fromB) {
changeIndex++;
const currentIndex = changeIndex;
let foundSpecialNode: { node: Node; pos: number } | null = null;
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromB <= pos && change.toB >= nodeEnd) {
foundSpecialNode = { node, pos };
return false;
}
}
});
if (foundSpecialNode) {
const nodeEnd =
foundSpecialNode.pos + foundSpecialNode.node.nodeSize;
decorations.push(
Decoration.node(foundSpecialNode.pos, nodeEnd, {
class: "history-diff-node-added",
"data-diff-index": String(currentIndex),
}),
);
} else {
decorations.push(
Decoration.inline(change.fromB, change.toB, {
class: "history-diff-added",
"data-diff-index": String(currentIndex),
}),
);
}
addedCount += 1;
}
if (change.toA > change.fromA) {
changeIndex++;
const currentIndex = changeIndex;
let foundDeletedNode: { node: Node; pos: number } | null = null;
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromA <= pos && change.toA >= nodeEnd) {
foundDeletedNode = { node, pos };
return false;
}
}
});
if (foundDeletedNode) {
decorations.push(
Decoration.widget(change.fromB, () => {
const wrapper = document.createElement("div");
wrapper.className = "history-diff-node-deleted";
wrapper.setAttribute("data-diff-index", String(currentIndex));
const serializer = DOMSerializer.fromSchema(schema);
const dom = serializer.serializeNode(foundDeletedNode!.node);
wrapper.appendChild(dom);
return wrapper;
}),
);
} else {
const deletedText = oldContent.textBetween(
change.fromA,
change.toA,
"",
);
if (deletedText) {
decorations.push(
Decoration.widget(change.fromB, () => {
const span = document.createElement("span");
span.className = "history-diff-deleted";
span.setAttribute("data-diff-index", String(currentIndex));
span.textContent = deletedText;
return span;
}),
);
}
}
deletedCount += 1;
}
}
decorationSet = DecorationSet.create(newContent, decorations);
} catch (e) {
console.error("History diff failed:", e);
editor.commands.setContent(content);
}
} else {
editor.commands.setContent(content); editor.commands.setContent(content);
} }
}, [title, content, editor]);
const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({
editorProps: {
...editor.options.editorProps,
decorations: () =>
highlightChanges ? decorationSet : DecorationSet.empty,
},
});
}, [
title,
content,
editor,
previousContent,
highlightChanges,
setDiffCounts,
]);
return ( return (
<> <div>
<div> <Title order={1}>{title}</Title>
<Title order={1}>{title}</Title> {editor && (
<EditorContent
{editor && <EditorContent editor={editor} />} editor={editor}
</div> className={historyClasses.historyEditor}
</> />
)}
</div>
); );
} }
@@ -1,20 +1,42 @@
import { Text, Group, UnstyledButton } from "@mantine/core"; import { Text, Group, UnstyledButton } from "@mantine/core";
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 "./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 { memo, useCallback } from "react";
interface HistoryItemProps { interface HistoryItemProps {
historyItem: any; historyItem: IPageHistory;
onSelect: (id: string) => void; index: number;
onSelect: (id: string, index: number) => void;
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean; isActive: boolean;
} }
function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) { const HistoryItem = memo(function HistoryItem({
historyItem,
index,
onSelect,
onHover,
onHoverEnd,
isActive,
}: HistoryItemProps) {
const handleClick = useCallback(() => {
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index);
}, [onHover, historyItem.id, index]);
return ( return (
<UnstyledButton <UnstyledButton
p="xs" p="xs"
onClick={() => onSelect(historyItem.id)} onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })} className={clsx(classes.history, { [classes.active]: isActive })}
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
@@ -27,11 +49,11 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<CustomAvatar <CustomAvatar
size="sm" size="sm"
avatarUrl={historyItem.lastUpdatedBy.avatarUrl} avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy.name} name={historyItem.lastUpdatedBy?.name}
/> />
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy.name} {historyItem.lastUpdatedBy?.name}
</Text> </Text>
</Group> </Group>
</div> </div>
@@ -39,6 +61,6 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
</Group> </Group>
</UnstyledButton> </UnstyledButton>
); );
} });
export default HistoryItem; export default HistoryItem;
@@ -1,29 +1,27 @@
import { import {
usePageHistoryListQuery, usePageHistoryListQuery,
usePageHistoryQuery, prefetchPageHistory,
} from "@/features/page-history/queries/page-history-query"; } from "@/features/page-history/queries/page-history-query";
import HistoryItem from "@/features/page-history/components/history-item"; import HistoryItem from "@/features/page-history/components/history-item";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom,
historyAtoms, historyAtoms,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import { useAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core";
import { import {
pageEditorAtom, Button,
titleEditorAtom, ScrollArea,
} from "@/features/editor/atoms/editor-atoms"; Group,
import { modals } from "@mantine/modals"; Divider,
import { notifications } from "@mantine/notifications"; Loader,
Center,
} from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { useHistoryRestore } from "@/features/page-history/hooks";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom"; const PREFETCH_DELAY_MS = 150;
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
interface Props { interface Props {
pageId: string; pageId: string;
@@ -32,62 +30,89 @@ interface Props {
function HistoryList({ pageId }: Props) { function HistoryList({ pageId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const { const {
data: pageHistoryList, data: pageHistoryData,
isLoading, isLoading,
isError, isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageHistoryListQuery(pageId); } = usePageHistoryListQuery(pageId);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const [mainEditor] = useAtom(pageEditorAtom); const historyItems = useMemo(
const [mainEditorTitle] = useAtom(titleEditorAtom); () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
const [, setHistoryModalOpen] = useAtom(historyAtoms); [pageHistoryData],
);
const { spaceSlug } = useParams(); const loadMoreRef = useRef<HTMLDivElement>(null);
const { data: space } = useSpaceQuery(spaceSlug); const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const confirmModal = () => const { canRestore, confirmRestore } = useHistoryRestore();
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<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,
});
const handleRestore = useCallback(() => { const clearPrefetchTimeout = useCallback(() => {
if (activeHistoryData) { if (prefetchTimeoutRef.current) {
mainEditorTitle clearTimeout(prefetchTimeoutRef.current);
.chain() prefetchTimeoutRef.current = null;
.clearContent()
.setContent(activeHistoryData.title, true)
.run();
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
} }
}, [activeHistoryData]); }, []);
const handleHover = useCallback(
(historyId: string, index: number) => {
clearPrefetchTimeout();
prefetchTimeoutRef.current = setTimeout(() => {
prefetchPageHistory(historyId);
const prevId = historyItems[index + 1]?.id;
if (prevId) {
prefetchPageHistory(prevId);
}
}, PREFETCH_DELAY_MS);
},
[clearPrefetchTimeout, historyItems],
);
useEffect(() => { useEffect(() => {
if ( return clearPrefetchTimeout;
pageHistoryList && }, [clearPrefetchTimeout]);
pageHistoryList.items.length > 0 &&
!activeHistoryId const handleSelect = useCallback(
) { (id: string, index: number) => {
setActiveHistoryId(pageHistoryList.items[0].id); setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
useEffect(() => {
if (historyItems.length > 0 && !activeHistoryId) {
setActiveHistoryId(historyItems[0].id);
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
} }
}, [pageHistoryList]); }, [
historyItems,
activeHistoryId,
setActiveHistoryId,
setActiveHistoryPrevId,
]);
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (isLoading) { if (isLoading) {
return <></>; return <></>;
@@ -97,34 +122,36 @@ function HistoryList({ pageId }: Props) {
return <div>{t("Error loading page history.")}</div>; return <div>{t("Error loading page history.")}</div>;
} }
if (!pageHistoryList || pageHistoryList.items.length === 0) { if (historyItems.length === 0) {
return <>{t("No page history saved yet.")}</>; return <>{t("No page history saved yet.")}</>;
} }
return ( return (
<div> <div>
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}> <ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
{pageHistoryList && {historyItems.map((historyItem, index) => (
pageHistoryList.items.map((historyItem, index) => ( <HistoryItem
<HistoryItem key={historyItem.id}
key={index} historyItem={historyItem}
historyItem={historyItem} index={index}
onSelect={setActiveHistoryId} onSelect={handleSelect}
isActive={historyItem.id === activeHistoryId} onHover={handleHover}
/> onHoverEnd={clearPrefetchTimeout}
))} isActive={historyItem.id === activeHistoryId}
/>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea> </ScrollArea>
{spaceAbility.cannot( {canRestore && (
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) ? null : (
<> <>
<Divider /> <Divider />
<Group p="xs" wrap="nowrap"> <Group p="xs" wrap="nowrap">
<Button size="compact-md" onClick={confirmModal}>
{t("Restore")}
</Button>
<Button <Button
variant="default" variant="default"
size="compact-md" size="compact-md"
@@ -132,6 +159,9 @@ function HistoryList({ pageId }: Props) {
> >
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
</Button>
</Group> </Group>
</> </>
)} )}
@@ -1,21 +1,45 @@
import { ScrollArea } from "@mantine/core"; import {
ActionIcon,
Group,
Paper,
ScrollArea,
Switch,
Text,
} from "@mantine/core";
import HistoryList from "@/features/page-history/components/history-list"; import HistoryList from "@/features/page-history/components/history-list";
import classes from "./history.module.css"; import classes from "./css/history.module.css";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms"; import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
highlightChangesAtom,
} 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 { useEffect } from "react"; import { useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useDiffNavigation,
useHistoryReset,
} from "@/features/page-history/hooks";
interface Props { interface Props {
pageId: string; pageId: string;
} }
export default function HistoryModalBody({ pageId }: Props) { export default function HistoryModalBody({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const { t } = useTranslation();
const scrollViewportRef = useRef<HTMLDivElement>(null);
useEffect(() => { const activeHistoryId = useAtomValue(activeHistoryIdAtom);
setActiveHistoryId(""); const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
}, [pageId]); const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
useDiffNavigation(scrollViewportRef);
return ( return (
<div className={classes.sidebarFlex}> <div className={classes.sidebarFlex}>
@@ -25,11 +49,63 @@ export default function HistoryModalBody({ pageId }: Props) {
</div> </div>
</nav> </nav>
<ScrollArea h="650" w="100%" scrollbarSize={5}> <div style={{ position: "relative", flex: 1 }}>
<div className={classes.sidebarRightSection}> <ScrollArea
{activeHistoryId && <HistoryView historyId={activeHistoryId} />} h={650}
</div> w="100%"
</ScrollArea> scrollbarSize={5}
viewportRef={scrollViewportRef}
>
<div className={classes.sidebarRightSection}>
{activeHistoryId && <HistoryView />}
</div>
</ScrollArea>
{activeHistoryId && activeHistoryPrevId && (
<Paper
shadow="md"
radius="xl"
px="md"
py="xs"
style={{
position: "absolute",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
}}
>
<Group gap="md" wrap="nowrap">
<Switch
label={t("Highlight changes")}
checked={highlightChanges}
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
/>
{highlightChanges && diffCounts && diffCounts.total > 0 && (
<Group gap="xs" wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{currentChangeIndex} of {diffCounts.total}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevChange}
>
<IconChevronUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNextChange}
>
<IconChevronDown size={16} />
</ActionIcon>
</Group>
)}
</Group>
</Paper>
)}
</div>
</div> </div>
); );
} }
@@ -0,0 +1,208 @@
import {
ActionIcon,
Box,
Button,
Group,
Paper,
ScrollArea,
Select,
Switch,
Text,
} from "@mantine/core";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
highlightChangesAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { formattedDate } from "@/lib/time";
import {
useDiffNavigation,
useHistoryReset,
useHistoryRestore,
} from "@/features/page-history/hooks";
import classes from "./css/history-mobile.module.css";
interface Props {
pageId: string;
pageTitle?: string;
}
export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
const { t } = useTranslation();
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const scrollViewportRef = useRef<HTMLDivElement>(null);
const dropdownViewportRef = useRef<HTMLDivElement>(null);
const {
data: pageHistoryData,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const selectData = useMemo(
() =>
historyItems.map((item) => ({
value: item.id,
label: formattedDate(new Date(item.createdAt)),
userName: item.lastUpdatedBy?.name,
})),
[historyItems],
);
useHistoryReset(pageId);
const { canRestore, confirmRestore } = useHistoryRestore();
const { currentChangeIndex, handlePrevChange, handleNextChange } =
useDiffNavigation(scrollViewportRef);
useEffect(() => {
if (historyItems.length > 0 && !activeHistoryId) {
setActiveHistoryId(historyItems[0].id);
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
}
}, [
historyItems,
activeHistoryId,
setActiveHistoryId,
setActiveHistoryPrevId,
]);
const handleDropdownScroll = useCallback(() => {
const viewport = dropdownViewportRef.current;
if (!viewport || !hasNextPage || isFetchingNextPage) return;
const { scrollTop, scrollHeight, clientHeight } = viewport;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50;
if (isNearBottom) {
fetchNextPage();
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const handleSelectVersion = useCallback(
(value: string | null) => {
if (!value) return;
const index = historyItems.findIndex((item) => item.id === value);
if (index >= 0) {
setActiveHistoryId(value);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
}
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
if (isLoading) {
return null;
}
return (
<Box className={classes.container}>
<Box className={classes.selectorWrapper}>
<Select
data={selectData}
value={activeHistoryId}
onChange={handleSelectVersion}
placeholder={t("Select version")}
checkIconPosition="right"
maxDropdownHeight={300}
renderOption={({ option, checked }) => (
<Group justify="space-between" wrap="nowrap" w="100%">
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="dimmed">
{(option as { userName?: string }).userName}
</Text>
</div>
{checked && <IconCheck size={16} />}
</Group>
)}
comboboxProps={{ withinPortal: false }}
scrollAreaProps={{
viewportRef: dropdownViewportRef,
onScrollPositionChange: handleDropdownScroll,
}}
/>
</Box>
<ScrollArea
className={classes.editorArea}
viewportRef={scrollViewportRef}
scrollbarSize={5}
>
<Box className={classes.editorContent}>
{activeHistoryId && <HistoryView />}
</Box>
</ScrollArea>
{canRestore && (
<Group className={classes.actionButtons} justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button>
</Group>
)}
{activeHistoryId && (
<Paper
shadow="sm"
radius="xl"
px="md"
py="xs"
className={classes.floatingBar}
>
<Group gap="sm" wrap="nowrap">
<Switch
label={t("Highlight changes")}
checked={highlightChanges}
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
size="sm"
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
/>
{highlightChanges && diffCounts && diffCounts.total > 0 && (
<Group gap={4} wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{currentChangeIndex} of {diffCounts.total}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevChange}
>
<IconChevronUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNextChange}
>
<IconChevronDown size={16} />
</ActionIcon>
</Group>
)}
</Group>
</Paper>
)}
</Box>
);
}
@@ -2,21 +2,26 @@ import { Modal, Text } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
import HistoryModalBody from "@/features/page-history/components/history-modal-body"; import HistoryModalBody from "@/features/page-history/components/history-modal-body";
import HistoryModalMobile from "@/features/page-history/components/history-modal-mobile";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mantine/hooks";
interface Props { interface Props {
pageId: string; pageId: string;
pageTitle?: string;
} }
export default function HistoryModal({ pageId }: Props) {
export default function HistoryModal({ pageId, pageTitle }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isModalOpen, setModalOpen] = useAtom(historyAtoms); const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
const isMobile = useMediaQuery("(max-width: 800px)");
return ( if (isMobile) {
<> return (
<Modal.Root <Modal.Root
size={1200}
opened={isModalOpen} opened={isModalOpen}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
fullScreen
> >
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
@@ -28,11 +33,37 @@ export default function HistoryModal({ pageId }: Props) {
</Modal.Title> </Modal.Title>
<Modal.CloseButton /> <Modal.CloseButton />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body
<HistoryModalBody pageId={pageId} /> p={0}
style={{ height: "calc(100vh - 60px)", overflow: "hidden" }}
>
<HistoryModalMobile pageId={pageId} pageTitle={pageTitle} />
</Modal.Body> </Modal.Body>
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
</> );
}
return (
<Modal.Root
size={1400}
opened={isModalOpen}
onClose={() => setModalOpen(false)}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>
{t("Page history")}
</Text>
</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<HistoryModalBody pageId={pageId} />
</Modal.Body>
</Modal.Content>
</Modal.Root>
); );
} }
@@ -1,29 +1,44 @@
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query"; import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import { HistoryEditor } from "@/features/page-history/components/history-editor"; import { HistoryEditor } from "@/features/page-history/components/history-editor";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms";
interface HistoryProps { function HistoryView() {
historyId: string;
}
function HistoryView({ historyId }: HistoryProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading, isError } = usePageHistoryQuery(historyId); const historyId = useAtomValue(activeHistoryIdAtom);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
if (isLoading) { const {
data,
isLoading: isLoadingCurrent,
isError: isErrorCurrent,
} = usePageHistoryQuery(historyId);
const {
data: prevData,
isLoading: isLoadingPrev,
isError: isErrorPrev,
} = usePageHistoryQuery(prevHistoryId);
if (isLoadingCurrent || isLoadingPrev) {
return <></>; return <></>;
} }
if (isError || !data) { if (isErrorCurrent || !data) {
return <div>{t("Error fetching page data.")}</div>; return <div>{t("Error fetching page data.")}</div>;
} }
return ( return (
data && ( <div>
<div> <HistoryEditor
<HistoryEditor content={data.content} title={data.title} /> content={data.content}
</div> title={data.title}
) previousContent={!isErrorPrev ? prevData?.content : undefined}
/>
</div>
); );
} }
@@ -1,37 +0,0 @@
.history {
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
}
}
.active {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
}
.sidebar {
max-height: rem(700px);
width: rem(250px);
padding: var(--mantine-spacing-sm);
display: flex;
flex-direction: column;
border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.sidebarFlex {
display: flex;
}
.sidebarMain {
flex: 1;
}
.sidebarRightSection {
flex: 1;
padding: rem(16px) rem(40px);
}
@@ -0,0 +1,3 @@
export { useDiffNavigation } from "./use-diff-navigation";
export { useHistoryRestore } from "./use-history-restore";
export { useHistoryReset } from "./use-history-reset";
@@ -0,0 +1,58 @@
import { useAtomValue } from "jotai";
import { RefObject, useCallback, useEffect, useState } from "react";
import { diffCountsAtom } from "@/features/page-history/atoms/history-atoms";
/**
* Manages navigation between diff changes in the history view.
* Provides prev/next handlers and auto-scrolls to the current change.
*/
export function useDiffNavigation(
scrollViewportRef: RefObject<HTMLDivElement>,
) {
const diffCounts = useAtomValue(diffCountsAtom);
const [currentChangeIndex, setCurrentChangeIndex] = useState(0);
const scrollToChangeIndex = useCallback(
(index: number) => {
const viewport = scrollViewportRef.current;
if (!viewport || index < 1) return;
const element = viewport.querySelector(`[data-diff-index="${index}"]`);
if (element instanceof HTMLElement) {
const elementTop = element.offsetTop;
const viewportHeight = viewport.clientHeight;
const scrollTarget =
elementTop - viewportHeight / 2 + element.offsetHeight / 2;
viewport.scrollTo({ top: scrollTarget, behavior: "smooth" });
}
},
[scrollViewportRef],
);
useEffect(() => {
if (diffCounts && diffCounts.total > 0) {
setCurrentChangeIndex(1);
requestAnimationFrame(() => scrollToChangeIndex(1));
} else {
setCurrentChangeIndex(0);
}
}, [diffCounts, scrollToChangeIndex]);
const handlePrevChange = useCallback(() => {
if (!diffCounts || diffCounts.total === 0) return;
const newIndex =
currentChangeIndex <= 1 ? diffCounts.total : currentChangeIndex - 1;
setCurrentChangeIndex(newIndex);
scrollToChangeIndex(newIndex);
}, [diffCounts, currentChangeIndex, scrollToChangeIndex]);
const handleNextChange = useCallback(() => {
if (!diffCounts || diffCounts.total === 0) return;
const newIndex =
currentChangeIndex >= diffCounts.total ? 1 : currentChangeIndex + 1;
setCurrentChangeIndex(newIndex);
scrollToChangeIndex(newIndex);
}, [diffCounts, currentChangeIndex, scrollToChangeIndex]);
return { currentChangeIndex, handlePrevChange, handleNextChange };
}
@@ -0,0 +1,24 @@
import { useAtom } from "jotai";
import { useEffect } from "react";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
} from "@/features/page-history/atoms/history-atoms";
/**
* Resets history state when pageId changes.
* Clears active selection and diff counts.
*/
export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
useEffect(() => {
setActiveHistoryId("");
setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null);
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
}
@@ -0,0 +1,78 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import { useParams } from "react-router-dom";
import {
activeHistoryIdAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
import { useSpaceQuery } from "@/features/space/queries/space-query";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type";
export function useHistoryRestore() {
const { t } = useTranslation();
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const mainEditor = useAtomValue(pageEditorAtom);
const mainEditorTitle = useAtomValue(titleEditorAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const { spaceSlug } = useParams();
const { data: space } = useSpaceQuery(spaceSlug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const canRestore = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
);
const handleRestore = useCallback(() => {
if (!activeHistoryData) return;
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
const confirmRestore = useCallback(() => {
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<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,
});
}, [t, handleRestore]);
return { canRestore, confirmRestore };
}
@@ -1,19 +1,38 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query"; import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import { import {
getPageHistoryById, getPageHistoryById,
getPageHistoryList, getPageHistoryList,
} from "@/features/page-history/services/page-history-service"; } from "@/features/page-history/services/page-history-service";
import { IPageHistory } from "@/features/page-history/types/page.types"; import { IPageHistory } from "@/features/page-history/types/page.types";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main";
const HISTORY_STALE_TIME = 60 * 60 * 1000;
export function prefetchPageHistory(historyId: string) {
return queryClient.prefetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery( export function usePageHistoryListQuery(
pageId: string, pageId: string,
): UseQueryResult<IPagination<IPageHistory>, Error> { ): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
return useQuery({ return useInfiniteQuery({
queryKey: ["page-history-list", pageId], queryKey: ["page-history-list", pageId],
queryFn: () => getPageHistoryList(pageId), queryFn: ({ pageParam }) => getPageHistoryList(pageId, pageParam),
enabled: !!pageId, enabled: !!pageId,
gcTime: 0, gcTime: 0,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
}); });
} }
@@ -24,6 +43,6 @@ export function usePageHistoryQuery(
queryKey: ["page-history", historyId], queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId), queryFn: () => getPageHistoryById(historyId),
enabled: !!historyId, enabled: !!historyId,
staleTime: 10 * 60 * 1000, staleTime: HISTORY_STALE_TIME,
}); });
} }
@@ -4,9 +4,11 @@ import { IPagination } from "@/lib/types.ts";
export async function getPageHistoryList( export async function getPageHistoryList(
pageId: string, pageId: string,
cursor?: string,
): Promise<IPagination<IPageHistory>> { ): Promise<IPagination<IPageHistory>> {
const req = await api.post("/pages/history", { const req = await api.post("/pages/history", {
pageId, pageId,
cursor,
}); });
return req.data; return req.data;
} }
@@ -7,22 +7,17 @@ import {
IconHistory, IconHistory,
IconLink, IconLink,
IconList, IconList,
IconMarkdown,
IconMessage, IconMessage,
IconPrinter, IconPrinter,
IconSearch,
IconTrash, IconTrash,
IconWifiOff, IconWifiOff,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import React from "react"; import React, { useEffect, useRef, useState } from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
getHotkeyHandler,
useClipboard,
useDisclosure,
useHotkeys,
} from "@mantine/hooks";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -34,12 +29,12 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal"; import ExportModal from "@/components/common/export-modal";
import { htmlToMarkdown } from "@docmost/editor-ext";
import { import {
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts"; } from "@/features/editor/atoms/editor-atoms.ts";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts"; import { formattedDate } from "@/lib/time.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx"; import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
@@ -51,7 +46,6 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const toggleAside = useToggleAside(); const toggleAside = useToggleAside();
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
useHotkeys( useHotkeys(
[ [
@@ -68,6 +62,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const event = new CustomEvent("closeFindDialogFromEditor", {}); const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
}, },
{ preventDefault: false },
], ],
], ],
[], [],
@@ -75,17 +70,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
return ( return (
<> <>
{yjsConnectionStatus === "disconnected" && ( <ConnectionWarning />
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
{!readOnly && <PageStateSegmentedControl size="xs" />} {!readOnly && <PageStateSegmentedControl size="xs" />}
@@ -146,6 +131,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
notifications.show({ message: t("Link copied") }); notifications.show({ message: t("Link copied") });
}; };
const handleCopyAsMarkdown = () => {
if (!pageEditor) return;
const html = pageEditor.getHTML();
const markdown = htmlToMarkdown(html);
const title = page?.title ? `# ${page.title}\n\n` : "";
clipboard.copy(`${title}${markdown}`);
notifications.show({ message: t("Copied") });
};
const handlePrint = () => { const handlePrint = () => {
setTimeout(() => { setTimeout(() => {
window.print(); window.print();
@@ -183,6 +177,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
> >
{t("Copy link")} {t("Copy link")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconMarkdown size={16} />}
onClick={handleCopyAsMarkdown}
>
{t("Copy as Markdown")}
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}> <Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
@@ -290,3 +291,51 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</> </>
); );
} }
function ConnectionWarning() {
const { t } = useTranslation();
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
const [showWarning, setShowWarning] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
if (isDisconnected) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
}
} else {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setShowWarning(false);
}
}, [yjsConnectionStatus]);
// Cleanup only on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
if (!showWarning) return null;
return (
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
);
}
@@ -172,6 +172,10 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
queryKey: ["root-sidebar-pages", fileTask.spaceId], queryKey: ["root-sidebar-pages", fileTask.spaceId],
}); });
await queryClient.invalidateQueries({
queryKey: ["recent-changes", fileTask.spaceId],
});
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "refetchRootTreeNodeEvent", operation: "refetchRootTreeNodeEvent",
@@ -163,9 +163,6 @@ export function useDeletePageMutation() {
export function useMovePageMutation() { export function useMovePageMutation() {
return useMutation<void, Error, IMovePage>({ return useMutation<void, Error, IMovePage>({
mutationFn: (data) => movePage(data), mutationFn: (data) => movePage(data),
onSuccess: () => {
invalidateOnMovePage();
},
}); });
} }
@@ -253,12 +250,10 @@ export function useGetSidebarPagesQuery(
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["sidebar-pages", data], queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId, enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }), queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }),
initialPageParam: 1, initialPageParam: undefined,
getPreviousPageParam: (firstPage) =>
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
getNextPageParam: (lastPage) => getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined, lastPage.meta?.nextCursor ?? undefined,
}); });
} }
@@ -266,13 +261,11 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId], queryKey: ["root-sidebar-pages", data.spaceId],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
return getSidebarPages({ spaceId: data.spaceId, page: pageParam }); return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam });
}, },
initialPageParam: 1, initialPageParam: undefined,
getPreviousPageParam: (firstPage) =>
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
getNextPageParam: (lastPage) => getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined, lastPage.meta?.nextCursor ?? undefined,
}); });
} }
@@ -458,17 +451,127 @@ export function invalidateOnUpdatePage(
}); });
} }
export function invalidateOnMovePage() { export function updateCacheOnMovePage(
//for move invalidate all sidebars for now (how to do???) spaceId: string,
//invalidate all root sidebar pages pageId: string,
queryClient.invalidateQueries({ oldParentId: string | null,
queryKey: ["root-sidebar-pages"], newParentId: string | null,
}); pageData: Partial<IPage>,
//invalidate all sub sidebar pages ) {
queryClient.invalidateQueries({ // Remove page from old parent's cache
queryKey: ["sidebar-pages"], const oldQueryKey =
}); oldParentId === null
// --- ? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
oldQueryKey,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((item) => item.id !== pageId),
})),
};
},
);
// Update old parent's hasChildren flag if it has no more children
if (oldParentId !== null) {
const oldParentCache = queryClient.getQueryData<
InfiniteData<IPagination<IPage>>
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
const remainingChildren =
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
if (remainingChildren === 0) {
// Update hasChildren in all caches where old parent appears
const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) =>
query.queryKey[0] === "root-sidebar-pages" ||
query.queryKey[0] === "sidebar-pages",
});
allSideBarMatches.forEach(([key]) => {
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
key,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.id === oldParentId
? { ...item, hasChildren: false }
: item,
),
})),
};
},
);
});
}
}
// Add page to new parent's cache
const newQueryKey =
newParentId === null
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: newParentId, spaceId }];
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
newQueryKey,
(old) => {
if (!old) return old;
// Check if page already exists in new location
const exists = old.pages.some((page) =>
page.items.some((item) => item.id === pageId),
);
if (exists) return old;
return {
...old,
pages: old.pages.map((page, index) => {
if (index === old.pages.length - 1) {
return {
...page,
items: [...page.items, pageData],
};
}
return page;
}),
};
},
);
// Update new parent's hasChildren flag
if (newParentId !== null) {
const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) =>
query.queryKey[0] === "root-sidebar-pages" ||
query.queryKey[0] === "sidebar-pages",
});
allSideBarMatches.forEach(([key]) => {
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.id === newParentId ? { ...item, hasChildren: true } : item,
),
})),
};
});
});
}
} }
export function invalidateOnDeletePage(pageId: string) { export function invalidateOnDeletePage(pageId: string) {
@@ -72,22 +72,19 @@ export async function getSidebarPages(
export async function getAllSidebarPages( export async function getAllSidebarPages(
params: SidebarPagesParams, params: SidebarPagesParams,
): Promise<InfiniteData<IPagination<IPage>, unknown>> { ): Promise<InfiniteData<IPagination<IPage>, unknown>> {
let page = 1; let cursor: string | undefined = undefined;
let hasNextPage = false;
const pages: IPagination<IPage>[] = []; const pages: IPagination<IPage>[] = [];
const pageParams: number[] = []; const pageParams: (string | undefined)[] = [];
do { do {
const req = await api.post("/pages/sidebar-pages", { ...params, page: page }); const req = await api.post("/pages/sidebar-pages", { ...params, cursor });
const data: IPagination<IPage> = req.data; const data: IPagination<IPage> = req.data;
pages.push(data); pages.push(data);
pageParams.push(page); pageParams.push(cursor);
hasNextPage = data.meta.hasNextPage; cursor = data.meta.nextCursor ?? undefined;
} while (cursor);
page += 1;
} while (hasNextPage);
return { return {
pageParams, pageParams,
@@ -118,7 +115,14 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
.split("filename=")[1] .split("filename=")[1]
.replace(/"/g, ""); .replace(/"/g, "");
saveAs(req.data, decodeURIComponent(fileName)); let decodedFileName = fileName;
try {
decodedFileName = decodeURIComponent(fileName);
} catch (err) {
// fallback to raw filename
}
saveAs(req.data, decodedFileName);
} }
export async function importPage(file: File, spaceId: string) { export async function importPage(file: File, spaceId: string) {
@@ -30,15 +30,15 @@ import { useState } from "react";
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal"; import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
import { UserInfo } from "@/components/common/user-info.tsx"; import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
export default function Trash() { export default function Trash() {
const { t } = useTranslation(); const { t } = useTranslation();
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { page, setPage } = usePaginateAndSearch(); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug); const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, { const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
page, limit: 50 cursor, limit: 50
}); });
const restorePageMutation = useRestorePageMutation(); const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation(); const deletePageMutation = useDeletePageMutation();
@@ -206,10 +206,10 @@ export default function Trash() {
{deletedPages && deletedPages.items.length > 0 && ( {deletedPages && deletedPages.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={deletedPages.meta?.hasPrevPage}
hasPrevPage={deletedPages.meta.hasPrevPage} hasNextPage={deletedPages.meta?.hasNextPage}
hasNextPage={deletedPages.meta.hasNextPage} onNext={() => goNext(deletedPages.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
</Stack> </Stack>
@@ -269,12 +269,15 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const prefetchPage = () => { const prefetchPage = () => {
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(async () => {
queryClient.prefetchQuery({ const page = await queryClient.fetchQuery({
queryKey: ["pages", node.data.slugId], queryKey: ["pages", node.data.id],
queryFn: () => getPageById({ pageId: node.data.slugId }), queryFn: () => getPageById({ pageId: node.data.id }),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
if (page?.slugId) {
queryClient.setQueryData(["pages", page.slugId], page);
}
}, 150); }, 150);
}; };
@@ -16,6 +16,7 @@ import {
useRemovePageMutation, useRemovePageMutation,
useMovePageMutation, useMovePageMutation,
useUpdatePageMutation, useUpdatePageMutation,
updateCacheOnMovePage,
} from "@/features/page/queries/page-query.ts"; } from "@/features/page/queries/page-query.ts";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -175,9 +176,25 @@ export function useTreeMutation<T>(spaceId: string) {
parentPageId: args.parentId, parentPageId: args.parentId,
}; };
const draggedNode = args.dragNodes[0];
const nodeData = draggedNode.data as SpaceTreeNode;
const oldParentId = nodeData.parentPageId ?? null;
const pageData = {
id: nodeData.id,
slugId: nodeData.slugId,
title: nodeData.name,
icon: nodeData.icon,
position: newPosition,
spaceId: nodeData.spaceId,
parentPageId: args.parentId,
hasChildren: nodeData.hasChildren,
};
try { try {
await movePageMutation.mutateAsync(payload); await movePageMutation.mutateAsync(payload);
updateCacheOnMovePage(spaceId, draggedNodeId, oldParentId, args.parentId, pageData);
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "moveTreeNode", operation: "moveTreeNode",
@@ -185,8 +202,10 @@ export function useTreeMutation<T>(spaceId: string) {
payload: { payload: {
id: draggedNodeId, id: draggedNodeId,
parentId: args.parentId, parentId: args.parentId,
oldParentId,
index: args.index, index: args.index,
position: newPosition, position: newPosition,
pageData,
}, },
}); });
}, 50); }, 50);
@@ -62,7 +62,7 @@ export interface ICopyPageToSpace {
export interface SidebarPagesParams { export interface SidebarPagesParams {
spaceId?: string; spaceId?: string;
pageId?: string; pageId?: string;
page?: number; // pagination cursor?: string;
} }
export interface IPageInput { export interface IPageInput {
@@ -18,7 +18,6 @@ import {
IconFileDescription, IconFileDescription,
IconSearch, IconSearch,
IconCheck, IconCheck,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
@@ -26,7 +25,7 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useLicense } from "@/ee/hooks/use-license"; import { useLicense } from "@/ee/hooks/use-license";
import classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
interface SearchSpotlightFiltersProps { interface SearchSpotlightFiltersProps {
@@ -53,7 +52,6 @@ export function SearchSpotlightFilters({
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ const { data: spacesData } = useGetSpacesQuery({
page: 1,
limit: 100, limit: 100,
query: debouncedSpaceQuery, query: debouncedSpaceQuery,
}); });
@@ -265,7 +263,9 @@ export function SearchSpotlightFilters({
contentType !== option.value && contentType !== option.value &&
handleFilterChange("contentType", option.value) handleFilterChange("contentType", option.value)
} }
disabled={option.disabled || (isAiMode && option.value === "attachment")} disabled={
option.disabled || (isAiMode && option.value === "attachment")
}
> >
<Group flex="1" gap="xs"> <Group flex="1" gap="xs">
<div> <div>
@@ -275,11 +275,13 @@ export function SearchSpotlightFilters({
{t("Enterprise")} {t("Enterprise")}
</Badge> </Badge>
)} )}
{!option.disabled && isAiMode && option.value === "attachment" && ( {!option.disabled &&
<Text size="xs" mt={4}> isAiMode &&
{t("Ask AI not available for attachments")} option.value === "attachment" && (
</Text> <Text size="xs" mt={4}>
)} {t("Ask AI not available for attachments")}
</Text>
)}
</div> </div>
{contentType === option.value && <IconCheck size={20} />} {contentType === option.value && <IconCheck size={20} />}
</Group> </Group>
@@ -10,8 +10,8 @@ import {
export async function searchPage( export async function searchPage(
params: IPageSearchParams, params: IPageSearchParams,
): Promise<IPageSearch[]> { ): Promise<IPageSearch[]> {
const req = await api.post<IPageSearch[]>("/search", params); const req = await api.post<{ items: IPageSearch[] }>("/search", params);
return req.data; return req.data.items;
} }
export async function searchSuggestions( export async function searchSuggestions(
@@ -24,13 +24,13 @@ export async function searchSuggestions(
export async function searchShare( export async function searchShare(
params: IPageSearchParams, params: IPageSearchParams,
): Promise<IPageSearch[]> { ): Promise<IPageSearch[]> {
const req = await api.post<IPageSearch[]>("/search/share-search", params); const req = await api.post<{ items: IPageSearch[] }>("/search/share-search", params);
return req.data; return req.data.items;
} }
export async function searchAttachments( export async function searchAttachments(
params: IPageSearchParams, params: IPageSearchParams,
): Promise<IAttachmentSearch[]> { ): Promise<IAttachmentSearch[]> {
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params); const req = await api.post<{ items: IAttachmentSearch[] }>("/search-attachments", params);
return req.data; return req.data.items;
} }
@@ -1,8 +1,9 @@
import { Table, Group, Text, Anchor } from "@mantine/core"; import { Table, Group, Text, Anchor } from "@mantine/core";
import React, { useState } from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts"; import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
import { ISharedItem } from "@/features/share/types/share.types.ts"; import { ISharedItem } from "@/features/share/types/share.types.ts";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -14,8 +15,8 @@ import classes from "./share.module.css";
export default function ShareList() { export default function ShareList() {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(1); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGetSharesQuery({ page }); const { data, isLoading } = useGetSharesQuery({ cursor });
return ( return (
<> <>
@@ -86,10 +87,10 @@ export default function ShareList() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
</> </>
@@ -8,7 +8,6 @@ import {
Switch, Switch,
Text, Text,
TextInput, TextInput,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react"; import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
@@ -21,12 +20,15 @@ import {
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { extractPageSlugId, getPageIcon } from "@/lib"; import { extractPageSlugId, getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import CopyTextButton from "@/components/common/copy.tsx"; import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl, isCloud } from "@/lib/config.ts"; import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css"; import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx"; import useTrial from "@/ee/hooks/use-trial.tsx";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
interface ShareModalProps { interface ShareModalProps {
readOnly: boolean; readOnly: boolean;
@@ -35,10 +37,18 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const pageId = extractPageSlugId(pageSlug); const pageSlugId = extractPageSlugId(pageSlug);
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const { data: share } = useShareForPageQuery(pageId); const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { isTrial } = useTrial(); const { isTrial } = useTrial();
const [workspace] = useAtom(workspaceAtom);
const { data: space } = useSpaceQuery(spaceSlug);
const workspaceDisabled =
workspace?.settings?.sharing?.disabled === true;
const spaceDisabled = space?.settings?.sharing?.disabled === true;
const sharingDisabled = workspaceDisabled || spaceDisabled;
const createShareMutation = useCreateShareMutation(); const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation(); const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation(); const deleteShareMutation = useDeleteShareMutation();
@@ -163,6 +173,20 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
{t("Upgrade Plan")} {t("Upgrade Plan")}
</Button> </Button>
</> </>
) : sharingDisabled ? (
<>
<Group justify="center" mb="sm">
<IconLock size={20} stroke={1.5} />
</Group>
<Text size="sm" ta="center" fw={500} mb="xs">
{t("Public sharing is disabled")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{workspaceDisabled
? t("Public sharing has been disabled at the workspace level.")
: t("Public sharing has been disabled for this space.")}
</Text>
</>
) : isDescendantShared ? ( ) : isDescendantShared ? (
<> <>
<Text size="sm">{t("Inherits public sharing from")}</Text> <Text size="sm">{t("Inherits public sharing from")}</Text>
@@ -27,15 +27,13 @@ import {
getShares, getShares,
updateShare, updateShare,
} from "@/features/share/services/share-service.ts"; } from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { useEffect } from "react";
export function useGetSharesQuery( export function useGetSharesQuery(
params?: QueryParams, params?: QueryParams,
): UseQueryResult<IPagination<ISharedItem>, Error> { ): UseQueryResult<IPagination<ISharedItem>, Error> {
return useQuery({ return useQuery({
queryKey: ["share-list"], queryKey: ["share-list", params],
queryFn: () => getShares(params), queryFn: () => getShares(params),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
@@ -72,7 +70,7 @@ export function useShareForPageQuery(
queryKey: ["share-for-page", pageId], queryKey: ["share-for-page", pageId],
queryFn: () => getShareForPage(pageId), queryFn: () => getShareForPage(pageId),
enabled: !!pageId, enabled: !!pageId,
staleTime: 0, staleTime: 60 * 1000,
retry: false, retry: false,
}); });
@@ -1,6 +1,7 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core"; import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useForm, zodResolver } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import * as z from "zod"; import * as z from "zod";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts"; import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
@@ -9,12 +10,12 @@ import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().trim().min(2).max(50), name: z.string().trim().min(2).max(100),
slug: z slug: z
.string() .string()
.trim() .trim()
.min(2) .min(2)
.max(50) .max(100)
.regex( .regex(
/^[a-zA-Z0-9]+$/, /^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters", "Space slug must be alphanumeric. No special characters",
@@ -6,6 +6,7 @@ import { ISpace } from "../types/space.types";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route"; import APP_ROUTE from "@/lib/app-route";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useState } from "react";
interface DeleteSpaceModalProps { interface DeleteSpaceModalProps {
space: ISpace; space: ISpace;
@@ -14,6 +15,7 @@ interface DeleteSpaceModalProps {
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) { export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [isDeleting, setIsDeleting] = useState(false);
const deleteSpaceMutation = useDeleteSpaceMutation(); const deleteSpaceMutation = useDeleteSpaceMutation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -35,12 +37,15 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
return; return;
} }
setIsDeleting(true);
try { try {
// pass slug too so we can clear the local cache // pass slug too so we can clear the local cache
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug }); await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
navigate(APP_ROUTE.HOME); navigate(APP_ROUTE.HOME);
} catch (error) { } catch (error) {
console.error("Failed to delete space", error); console.error("Failed to delete space", error);
} finally {
setIsDeleting(false);
} }
}; };
@@ -79,7 +84,7 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
<Button onClick={close} variant="default"> <Button onClick={close} variant="default">
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={handleDelete} color="red"> <Button onClick={handleDelete} color="red" loading={isDeleting}>
{t("Confirm")} {t("Confirm")}
</Button> </Button>
</Group> </Group>

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