Compare commits

..
Author SHA1 Message Date
Philipinho 0c6f2299b2 Merge branch 'main' into feat/integrations 2026-08-13 01:33:47 +01:00
Philip Okugbe ea59912c7e feat(editor): auto-detect text direction for RTL support (#2389) 2026-08-12 23:48:03 +01:00
Philipinho db3ff54da1 fix base node 2026-08-12 16:47:32 +01:00
Philip Okugbe 9414a38215 feat: page attachments endpoint and modal (#2386)
* feat: page attachments endpoint

* feat: attachments modal
2026-08-12 14:04:33 +01:00
Philipinho 089286f6cf fix: delete pnpm cache 2026-08-12 12:33:34 +01:00
Philip Okugbe 737cd67965 feat: compare two page versions (#2385) 2026-08-12 12:31:02 +01:00
Philip Okugbe a0b2ac6ae3 feat: footnotes (#2384)
* feat: footnotes

* feat: proper DOCX support
2026-08-12 12:30:30 +01:00
Philip Okugbe 7439da2f6e chore: package updates (#2383)
* update

* update versions

* package updates
2026-08-10 17:56:12 +01:00
Philip Okugbe 305fd40686 fix: browser tab showing url instead of page title (#2382) 2026-08-10 17:38:03 +01:00
Philipinho 4bd51d7404 fix: self-reference parent page 2026-08-10 17:37:12 +01:00
Philipinho 9bd5ddee69 fix(integration): show disconnect loader only on the affected row 2026-08-10 03:43:45 +01:00
Philipinho 9f279d191c wip 2026-08-10 03:41:54 +01:00
Philipinho 311b511afb fix(integration): send provider and oauth requests through configured proxy 2026-08-10 03:24:19 +01:00
Philipinho bf56b7c2c2 chore(integration): document cache purge intent 2026-08-09 15:58:34 +01:00
Philipinho 15cbcfb78d fix(integration): purge per-user unfurl cache on disconnect 2026-08-09 15:51:53 +01:00
Philipinho 2829491b2d fix(integration): refresh connections list after disconnect 2026-08-09 15:50:03 +01:00
Philipinho 6eb0cf2b31 fix(integration): drop cached unfurls when disconnecting an integration 2026-08-09 15:35:37 +01:00
Philipinho a351ecd77a fix(editor-ext): stop persisting unfurl data on integration link nodes 2026-08-09 15:35:05 +01:00
Philipinho 99bcf3da9d fix(integration): resolve unfurls per viewer at render time 2026-08-09 15:18:43 +01:00
Philipinho 16f7139e6f wip 2026-08-09 15:01:52 +01:00
Philipinho 21665fc0e7 fix(integration): thread requester identity through unfurl for per-user authorization 2026-08-08 23:19:23 +01:00
Philipinho c29249fe13 fix(slack): enforce space create permission for page creation handlers 2026-08-08 23:14:03 +01:00
Philipinho f471335549 feat(page): add validateCanCreate space permission check 2026-08-08 23:13:58 +01:00
Philipinho fed7d78495 wip 2026-08-08 22:25:33 +01:00
Philipinho 92b6513f39 feat(slack): audit comment replies posted from notification DMs 2026-08-08 20:50:24 +01:00
Philipinho 903151877a feat(audit): stop excluding comment creation from audit logs 2026-08-08 20:50:24 +01:00
Philipinho a740fb03f8 feat(slack): quote comment bodies and reply from notification DMs 2026-08-08 20:23:08 +01:00
Philipinho 59ec9ed1e6 fix(slack): use workspace-canonical urls for cloud multi-tenancy 2026-08-08 19:38:52 +01:00
Philipinho c37e428808 feat(slack): unfurl links based on the posting user's page access 2026-08-08 18:44:21 +01:00
Philipinho 618e1ecfc3 feat(slack): trim link unfurls to title and metadata only 2026-08-08 18:10:42 +01:00
Philipinho fe61b2e854 feat(slack): DM verification and approval notifications 2026-08-08 11:56:40 +01:00
Philipinho edf4c52b81 feat(slack): docmost notify DM notifications 2026-08-08 03:40:44 +01:00
Philipinho 6d17224cae feat(integration): add kind-filtered user link lookup by user id 2026-08-08 02:22:05 +01:00
Philipinho 9555537f27 feat(notification): emit notification.created event and add slack notify queue constants 2026-08-08 01:50:10 +01:00
Philipinho 139cc82326 Merge branch 'main' into feat/integrations
# Conflicts:
#	apps/client/src/App.tsx
#	apps/server/src/ee
#	apps/server/src/integrations/queue/constants/queue.constants.ts
#	apps/server/src/integrations/queue/queue.module.ts
#	packages/editor-ext/src/index.ts
2026-08-07 01:13:39 +01:00
Philip Okugbe 89378ee766 chore: package updates (#2378)
* chore: package updates

* remove stale overrides
2026-08-06 23:41:36 +01:00
Philip Okugbe 56ac42767a fix: stale placeholder decorations on empty paragraphs (#2374) 2026-08-02 21:07:50 +01:00
Philipinho 38380211a5 fix: dedupe duplicate packages 2026-08-01 11:57:05 +01:00
Philip Okugbe cd34594b5d fix: placeholder bug (#2373)
* fix placeholder check

* tiptap v3.29.2

* add s3 max socket env
2026-08-01 11:45:25 +01:00
Philip Okugbe 70d2ff8685 fix: increase s3 client max socket limit (#2355) 2026-07-21 22:59:25 +01:00
Philipinho 0ba2d78660 chore: update pnpm and axios 2026-07-20 20:46:02 +01:00
Philip Okugbe 3ed505d2be fix: toolbar flicker on navigation (#2352) 2026-07-20 20:36:03 +01:00
Philip Okugbe a55057db37 feat: collab hocuspocus v4 upgrade (#2351)
* WIP 1

* complete v4 migration

* add flushDelay

* feat: multiplexing

* fix: survive hocuspocus v4 message timeout

* fix: searchTerm type error
2026-07-20 20:34:09 +01:00
Philip Okugbe ce8fbb86ff chore: upgrade to node 26 and pnpm 11 (#2342)
* chore: pnpm 11

* chore: set minimum age and allowedBuilds

* Node 26; pnpm 11

* prod package overrides

* dev package overides

* cleanup
2026-07-20 19:44:51 +01:00
Philipinho e28df6ad88 fix 2026-07-04 14:11:44 +01:00
Philip Okugbe 4132dd597c New Crowdin updates (#2323) 2026-07-03 11:57:07 +01:00
Philip Okugbe 8a2c457ab9 feat: kanban group editing (#2322)
* feat: support opening property menu directly on options panel

* refactor: expose group-by property from useKanbanColumns

* feat: rename kanban group by clicking column name

* feat: edit group-by property from kanban column menu

* fix: license-gate base and kanban inserts

* fix: add missing bases strings to en-US translation file
2026-07-03 11:35:26 +01:00
Philipinho 6ff7f69137 fix: file import page title 2026-06-30 02:45:07 +01:00
Philipinho 481aae5507 sync 2026-06-30 02:36:52 +01:00
Philipinho 12d68765d9 v0.95.0 2026-06-30 01:33:34 +01:00
Philipinho 27a80b57f8 sync 2026-06-30 01:32:19 +01:00
Philip Okugbe 15bdf680d9 feat: workspace default edit preference (#2319) 2026-06-30 01:10:51 +01:00
Philipinho 96faff7175 fix: reactive subpage list 2026-06-29 19:58:22 +01:00
Philipinho 2959504470 fix tree merge 2026-06-29 19:02:21 +01:00
Philip Okugbe 37a88d171a New Crowdin updates (#2318) 2026-06-29 19:01:02 +01:00
Philip Okugbe c86d556888 fix: templates ui fixes (#2316)
* fix: editor menus
* fix: templates creation UI gating
2026-06-29 12:32:09 +01:00
Philipinho 3e3e09133b chore(ee): bump submodule for home view re-publish on link 2026-05-23 19:03:41 +01:00
Philipinho 4c266f832a chore(ee): bump submodule for home-search submit handler 2026-05-23 19:00:55 +01:00
Philipinho f4bae91dd0 chore(ee): bump submodule for home-search handler 2026-05-23 18:58:22 +01:00
Philipinho aee6781a5a chore(ee): bump submodule for home-search modal builder 2026-05-23 18:55:56 +01:00
Philipinho eaac79f25d chore(ee): bump submodule for create-document submit handler 2026-05-23 18:53:44 +01:00
Philipinho 49f5aaa880 chore(ee): bump submodule for home-create-doc handler 2026-05-23 18:46:30 +01:00
Philipinho 4e5d82c847 chore(ee): bump submodule for create-document modal builder 2026-05-23 18:44:27 +01:00
Philipinho 407d86c65c chore(ee): bump submodule for app_home_opened handler 2026-05-23 18:42:48 +01:00
Philipinho 1690691de0 chore(ee): bump submodule for slack home view block builder 2026-05-23 18:38:47 +01:00
Philipinho 4db5281c11 feat(slack): wire /docmost ask queue and bump submodule
- Add SLACK_ASK queue name and job constant for AI search backgrounding.
- Bump ee submodule to pick up new Slack handlers and converters.
2026-05-23 18:35:13 +01:00
Philipinho 2d74455c2b refactor(integrations): consolidate integrations schema into baseline migration 2026-05-23 13:11:28 +01:00
Philipinho 6a870adec9 fix(integrations): partial unique index so user-link doesn't clobber workspace row 2026-05-23 13:06:47 +01:00
Philipinho b4c917ac07 feat(integrations): defer workspace-scoped install until OAuth succeeds 2026-05-23 12:38:43 +01:00
Philipinho 91a2abd8d3 feat(integrations): route OAuth callbacks back to originating workspace host 2026-05-23 12:32:18 +01:00
Philipinho 2f313187ab feat(integrations): one-step install for workspace-scoped providers 2026-05-23 12:22:26 +01:00
Philipinho 2c80459674 chore(ee): bump submodule for completeLink workspace check 2026-05-23 12:08:51 +01:00
Philipinho a3897e1485 fix(integrations): don't wipe workspace bot row on per-user disconnect 2026-05-23 12:08:49 +01:00
Philipinho 305f2be527 chore(ee): bump submodule for Disconnect fix 2026-05-23 12:01:38 +01:00
Philipinho 3e86bef2b4 chore(ee): bump submodule for Slack disconnect button handler 2026-05-23 11:55:13 +01:00
Philipinho 9603ff4287 chore(ee): bump submodule for new-page modal and app_uninstalled handler 2026-05-23 05:17:20 +01:00
Philipinho 9696e06635 chore(ee): bump submodule for /docmost save and add-to-page shortcut 2026-05-23 05:10:03 +01:00
Philipinho 4c7c3ac44d chore(ee): bump submodule for page-picker review fixes 2026-05-23 05:06:44 +01:00
Philipinho 02d20c1ab6 chore(ee): bump submodule for page-picker submit and options handlers 2026-05-23 04:57:46 +01:00
Philipinho 013fa923e0 chore(ee): bump submodule for mrkdwn converter, page writer, picker blocks 2026-05-23 04:53:14 +01:00
Philipinho 872321c58c chore(ee): bump submodule for Slack workspace connectionScope 2026-05-23 04:35:59 +01:00
Philipinho 0ab6ae86e3 feat(integrations): add connectionScope (workspace|user) to OAuth flow 2026-05-23 04:35:59 +01:00
Philipinho 31043a9d98 chore(ee): bump submodule for SlackProvider.onConnected and SkipTransform 2026-05-23 04:17:21 +01:00
Philipinho 05bd04d87b feat(integrations): pass integrationId and workspaceId to provider.onConnected 2026-05-23 04:17:19 +01:00
Philipinho 677b5f95e0 chore(ee): bump submodule for recents, favs, search commands 2026-05-23 03:23:22 +01:00
Philipinho e44a0cd0e0 feat(search): add titlesOnly option to searchPage for efficient title-only searches 2026-05-23 03:22:29 +01:00
Philipinho a3755a2469 chore(ee): bump submodule for /docmost help command 2026-05-23 03:15:25 +01:00
Philipinho ba81f7143b chore(ee): bump submodule for LinkSharedHandler 2026-05-23 03:11:12 +01:00
Philipinho a14e239067 chore(ee): bump submodule for SlackUrlParser and unfurl blocks 2026-05-23 03:06:07 +01:00
Philipinho 9a225c07c3 feat(page): add canPageBeUnfurled predicate for Slack link previews 2026-05-23 03:05:05 +01:00
Philipinho 19107c3f8c chore(ee): bump submodule for expanded Slack OAuth scopes 2026-05-23 03:02:36 +01:00
Philipinho ddd309bf07 chore(ee): bump submodule for outbound channel-notif removal 2026-05-23 02:53:33 +01:00
Philipinho 68bc936e14 refactor(integrations): drop slackSettingsSchema and related types 2026-05-23 02:52:31 +01:00
Philipinho 03926e9f77 chore(ee): bump submodule for SlackController, processor, module wiring 2026-05-23 02:36:13 +01:00
Philipinho 6dce0e4eb5 feat(integrations): add SLACK_INBOUND queue and excluded auth paths 2026-05-23 02:35:00 +01:00
Philipinho d68cff0ee9 chore(ee): bump submodule for handler interface and dispatchers 2026-05-23 02:30:41 +01:00
Philipinho 75c79cbe9e feat(slack): add browser-side identity link confirmation page 2026-05-23 02:23:11 +01:00
Philipinho 32020c7c89 chore(ee): bump submodule for Slack link controller 2026-05-23 02:19:10 +01:00
Philipinho 7ff1e87a9a chore(ee): bump submodule for SlackIdentityResolver 2026-05-23 02:17:13 +01:00
Philipinho f3b7b1acc7 feat(integrations): add upsertUserLink for per-user identity bindings 2026-05-23 02:15:51 +01:00
Philipinho c8df56ceb2 chore(ee): bump submodule for link-prompt blocks 2026-05-23 02:12:02 +01:00
Philipinho d64562ae14 chore(ee): bump submodule for slack-link-state helper 2026-05-23 02:10:38 +01:00
Philipinho c2063e6c3d fix(env): match INTEGRATION_{TYPE}_* env-var convention for Slack signing secret 2026-05-23 02:08:07 +01:00
Philipinho 0d58b88db8 fix(integrations): don't filter per-user connections out of existing lookups 2026-05-23 02:04:47 +01:00
Philipinho 7e1bef8b7e feat(integrations): add kind-aware helpers for workspace/user connections 2026-05-23 02:02:35 +01:00
Philipinho d53054a267 feat(integrations): add findByTypeAndSettingsField for JSONB lookup 2026-05-23 02:01:06 +01:00
Philipinho a1f6aa1e43 feat(integrations): pass raw token-response body to provider.onConnected 2026-05-23 01:58:26 +01:00
Philipinho 4d8a5254f9 chore(ee): bump submodule for SlackSignatureGuard comment 2026-05-23 01:57:18 +01:00
Philipinho ff567e0cb4 chore(ee): bump submodule for SlackSignatureGuard 2026-05-23 01:51:21 +01:00
Philipinho 2cedf9ea6c feat(env): add SLACK_SIGNING_SECRET getter on EnvironmentService 2026-05-23 01:50:46 +01:00
Philipinho d1b46e383a chore(ee): bump submodule for SlackApiClient review fixes 2026-05-23 01:47:41 +01:00
Philipinho ca4207ef0a chore(ee): bump submodule for SlackApiClient 2026-05-23 01:40:33 +01:00
Philipinho 88888e1dc7 fix(integrations): scope Slack-kind backfill, fix index where-clause types 2026-05-23 01:35:08 +01:00
Philipinho cc17faf835 feat(integrations): add kind discriminator and nullable token to connections 2026-05-23 01:22:32 +01:00
Philipinho 392aa0aef9 remove zod 2026-05-22 23:00:14 +01:00
Philipinho af9e777009 merge main 2026-05-22 13:56:51 +01:00
Philipinho 8a0217527f token refresh, and filter inactive connections 2026-02-23 02:48:42 +00:00
Philipinho 5c3e715a10 refactor design 2026-02-23 02:33:03 +00:00
Philipinho 04f218833f UI 2026-02-22 23:58:09 +00:00
Philipinho 4664b6ac1d icons 2026-02-22 23:47:13 +00:00
Philipinho 602aaeb641 wip 2026-02-22 23:15:47 +00:00
Philipinho 81c6fb0d56 fix 2026-02-22 22:32:40 +00:00
Philipinho aeb30ad096 feat: integrations 2026-02-22 20:14:33 +00:00
226 changed files with 14421 additions and 3846 deletions
+11
View File
@@ -51,6 +51,17 @@ DRAWIO_URL=
# Gotenberg URL for server-side PDF export
GOTENBERG_URL=
# Integration OAuth apps (create one per provider you enable)
INTEGRATION_GITHUB_CLIENT_ID=
INTEGRATION_GITHUB_CLIENT_SECRET=
# Set only for GitHub Enterprise Server, e.g https://github.example.com
INTEGRATION_GITHUB_BASE_URL=
INTEGRATION_GITLAB_CLIENT_ID=
INTEGRATION_GITLAB_CLIENT_SECRET=
# Set only for self-hosted GitLab, e.g https://gitlab.example.com
INTEGRATION_GITLAB_BASE_URL=
DISABLE_TELEMETRY=false
# Allow other sites to embed Docmost in an iframe.
-1
View File
@@ -1 +0,0 @@
shamefully-hoist = true
+3 -4
View File
@@ -1,7 +1,7 @@
FROM node:22-slim AS base
FROM node:26-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@10.4.0
RUN npm install -g pnpm@11.15.1
FROM base AS builder
@@ -34,7 +34,6 @@ COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-f
# Copy root package files
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches
COPY --from=builder /app/patches /app/patches
@@ -43,7 +42,7 @@ RUN chown -R node:node /app
USER node
RUN pnpm install --frozen-lockfile --prod
RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm
RUN mkdir -p /app/data/storage
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.90.1",
"version": "0.95.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -36,7 +36,7 @@
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.3",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"axios": "1.18.1",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"file-saver": "2.0.5",
@@ -50,9 +50,9 @@
"katex": "0.16.40",
"lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mermaid": "11.16.1",
"mitt": "3.0.1",
"nanoid": "3.3.8",
"nanoid": "3.3.17",
"posthog-js": "1.391.2",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
@@ -61,7 +61,7 @@
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "7.18.0",
"react-router-dom": "7.18.2",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
@@ -86,7 +86,7 @@
"globals": "15.13.0",
"jsdom": "25.0.0",
"optics-ts": "2.4.1",
"postcss": "8.5.14",
"postcss": "8.5.25",
"postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.1",
@@ -41,6 +41,8 @@
"Dark": "Dunkel",
"Date": "Datum",
"Delete": "Löschen",
"Remove from page": "Von Seite entfernen",
"Base options": "Basisoptionen",
"Delete group": "Gruppe löschen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dabei werden auch alle Unterseiten und der Seitenverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"Description": "Beschreibung",
@@ -76,6 +78,24 @@
"Failed to import pages": "Import der Seiten fehlgeschlagen",
"Failed to load page. An error occurred.": "Seite konnte nicht geladen werden. Es ist ein Fehler aufgetreten.",
"Failed to update data": "Aktualisierung der Daten fehlgeschlagen",
"Failed to create base": "Basis konnte nicht erstellt werden",
"Failed to update base": "Basis konnte nicht aktualisiert werden",
"Failed to delete base": "Basis konnte nicht gelöscht werden",
"Failed to create property": "Eigenschaft konnte nicht erstellt werden",
"Failed to update property": "Eigenschaft konnte nicht aktualisiert werden",
"Failed to delete property": "Eigenschaft konnte nicht gelöscht werden",
"Failed to reorder property": "Eigenschaft konnte nicht neu angeordnet werden",
"Failed to create view": "Ansicht konnte nicht erstellt werden",
"Failed to update view": "Ansicht konnte nicht aktualisiert werden",
"Failed to delete view": "Ansicht konnte nicht gelöscht werden",
"Failed to create row": "Zeile konnte nicht erstellt werden",
"Failed to update row": "Zeile konnte nicht aktualisiert werden",
"Failed to delete row": "Zeile konnte nicht gelöscht werden",
"Failed to delete rows": "Zeilen konnten nicht gelöscht werden",
"Failed to reorder row": "Zeile konnte nicht neu angeordnet werden",
"Failed to move card": "Karte konnte nicht verschoben werden",
"Failed to add card": "Karte konnte nicht hinzugefügt werden",
"Failed to export CSV": "CSV konnte nicht exportiert werden",
"Favorite spaces": "Favorisierte Bereiche",
"Favorite spaces appear here": "Favorisierte Bereiche werden hier angezeigt",
"Favorites": "Favoriten",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Mermaid-Diagramm einfügen",
"Insert and design Drawio diagrams": "Drawio-Diagramme einfügen und gestalten",
"Insert current date": "Aktuelles Datum einfügen",
"Time": "Uhrzeit",
"Insert current time": "Aktuelle Uhrzeit einfügen",
"Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren",
"Multiple": "Mehrfach",
"Turn into": "In verwandeln",
@@ -424,6 +446,7 @@
"Names do not match": "Namen stimmen nicht überein",
"Today, {{time}}": "Heute, {{time}}",
"Yesterday, {{time}}": "Gestern, {{time}}",
"now": "jetzt",
"Space created successfully": "Bereich erfolgreich erstellt",
"Space updated successfully": "Bereich erfolgreich aktualisiert",
"Space deleted successfully": "Bereich erfolgreich gelöscht",
@@ -596,6 +619,8 @@
"Deleted by": "Gelöscht von",
"Deleted at": "Gelöscht am",
"Preview": "Vorschau",
"Base preview unavailable": "Basisvorschau nicht verfügbar",
"Restore this base to view its contents.": "Stelle diese Basis wieder her, um ihren Inhalt anzuzeigen.",
"Subpages": "Unterseiten",
"Failed to load subpages": "Unterseiten konnten nicht geladen werden",
"No subpages": "Keine Unterseiten",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Sind Sie sicher, dass Sie diese Vorlage löschen möchten?",
"Template scope updated": "Vorlagenbereich aktualisiert",
"Choose which space this template belongs to": "Wählen Sie den Bereich aus, zu dem diese Vorlage gehört",
"Only workspace admins can create global templates.": "Nur Workspace-Admins können globale Vorlagen erstellen.",
"You need edit access to this space to create templates.": "Sie benötigen Bearbeitungszugriff auf diesen Space, um Vorlagen zu erstellen.",
"Scope": "Bereich",
"Select scope": "Bereich auswählen",
"Title": "Titel",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Seiten und Bereiche suchen...",
"No results found": "Keine Ergebnisse gefunden",
"You don't have permission to create pages here": "Sie haben hier keine Berechtigung, Seiten zu erstellen",
"Chat menu": "Chatmenü",
"Chat menu for {{title}}": "Chat-Menü für {{title}}",
"API key menu": "API-Schlüssel-Menü",
"Jump to comment selection": "Zur Kommentarauswahl springen",
"Slash commands": "Slash-Befehle",
@@ -1063,7 +1090,7 @@
"Filter": "Filter",
"Page title": "Seitentitel",
"Page content": "Seiteninhalt",
"Member actions": "Mitgliederaktionen",
"Member actions for {{name}}": "Mitgliederaktionen für {{name}}",
"Toggle password visibility": "Passwortsichtbarkeit umschalten",
"Send comment": "Kommentar senden",
"Token actions": "Token-Aktionen",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "{{name}} zu Favoriten hinzugefügt",
"Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt",
"Page menu for {{name}}": "Seitenmenü für {{name}}",
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen"
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen",
"Allow personal spaces": "Persönliche Bereiche erlauben",
"Members can create their own personal space.": "Mitglieder können ihren eigenen persönlichen Bereich erstellen.",
"Toggle allow personal spaces": "Persönliche Bereiche erlauben umschalten",
"Create personal space": "Persönlichen Bereich erstellen",
"Personal space": "Persönlicher Bereich",
"{{name}}'s space": "Bereich von {{name}}",
"Apply": "Anwenden",
"Cells that aren't already a page reference will be cleared.": "Zellen, die noch keine Seitenreferenz sind, werden geleert.",
"Cells that aren't a valid URL will be cleared.": "Zellen, die keine gültige URL sind, werden geleert.",
"Cells that aren't a valid email address will be cleared.": "Zellen, die keine gültige E-Mail-Adresse sind, werden geleert.",
"Cells that can't be parsed as a date will be cleared.": "Zellen, die nicht als Datum geparst werden können, werden geleert.",
"Cells that can't be parsed as a number will be cleared.": "Zellen, die nicht als Zahl geparst werden können, werden geleert.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Zellen werden umgewandelt (yes/true/1 werden als ausgewählt markiert; alles andere wird als nicht ausgewählt markiert oder geleert).",
"Cells will be reinterpreted under the new type.": "Zellen werden unter dem neuen Typ neu interpretiert.",
"Cells will be replaced with a comma-separated list of file names.": "Zellen werden durch eine kommagetrennte Liste von Dateinamen ersetzt.",
"Cells will be replaced with a comma-separated list of option names.": "Zellen werden durch eine kommagetrennte Liste von Optionsnamen ersetzt.",
"Cells will be replaced with the option name.": "Zellen werden durch den Optionsnamen ersetzt.",
"Cells will be replaced with the page title.": "Zellen werden durch den Seitentitel ersetzt.",
"Cells will be replaced with the person's name.": "Zellen werden durch den Namen der Person ersetzt.",
"Change type": "Typ ändern",
"Change type to {{label}}?": "Typ in {{label}} ändern?",
"Converting…": "Wird umgewandelt…",
"Existing values become single-item lists. No data is lost.": "Vorhandene Werte werden zu Einzellisten. Es gehen keine Daten verloren.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Nur das erste ausgewählte Element pro Zeile wird beibehalten; der Rest wird verworfen.",
"Previous record": "Vorheriger Datensatz",
"Next record": "Nächster Datensatz",
"Record actions": "Datensatzaktionen",
"Delete record": "Datensatz löschen",
"Delete record?": "Datensatz löschen?",
"This action cannot be undone.": "Diese Aktion kann nicht rückgängig gemacht werden.",
"to navigate": "zum Navigieren",
"to close": "zum Schließen",
"Expand row {{number}}": "Zeile {{number}} erweitern",
"Saving…": "Wird gespeichert…",
"Read-only": "Schreibgeschützt",
"Loading…": "Wird geladen…",
"Updated {{when}}": "Aktualisiert {{when}}",
"Add property": "Eigenschaft hinzufügen",
"Create property": "Eigenschaft erstellen",
"Hide properties": "Eigenschaften ausblenden",
"Find a property type": "Einen Eigenschaftstyp finden",
"Properties": "Eigenschaften",
"A property with this name already exists": "Eine Eigenschaft mit diesem Namen existiert bereits",
"Add card": "Karte hinzufügen",
"Add filter": "Filter hinzufügen",
"Add option": "Option hinzufügen",
"Add sort": "Sortierung hinzufügen",
"Add view": "Ansicht hinzufügen",
"All data in this column will be lost.": "Alle Daten in dieser Spalte gehen verloren.",
"Allow multiple people": "Mehrere Personen zulassen",
"Alphabetize": "Alphabetisch sortieren",
"Are you sure you want to delete": "Sind Sie sicher, dass Sie löschen möchten",
"Ascending": "Aufsteigend",
"Base deleted": "Base gelöscht",
"Base name": "Base-Name",
"Base table": "Base-Tabelle",
"Card properties": "Karteneigenschaften",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Zellen, die das Textlimit überschreiten, werden abgeschnitten und der zusätzliche Inhalt geht dauerhaft verloren.",
"Checkbox": "Kontrollkästchen",
"Checked by default": "Standardmäßig aktiviert",
"Choose a property": "Wählen Sie eine Eigenschaft",
"Clear selection": "Auswahl aufheben",
"Column options": "Spaltenoptionen",
"Comma, period": "Komma, Punkt",
"Complete": "Abgeschlossen",
"Contains": "Enthält",
"Copy link to view": "Link zur Ansicht kopieren",
"Create a status property": "Eine Statuseigenschaft erstellen",
"Created at": "Erstellt am",
"Currency": "Währung",
"Decimal places": "Dezimalstellen",
"Default value": "Standardwert",
"Delete property": "Eigenschaft löschen",
"Delete view": "Ansicht löschen",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Absteigend",
"Discard": "Verwerfen",
"Doesn't contain": "Enthält nicht",
"Done": "Fertig",
"Edit formula": "Formel bearbeiten",
"Edit property": "Eigenschaft bearbeiten",
"Export CSV": "CSV exportieren",
"Failed to load base": "Base konnte nicht geladen werden",
"False": "Falsch",
"File": "Datei",
"Filter and sort changes are visible only to you": "Änderungen an Filtern und Sortierungen sind nur für Sie sichtbar",
"Filter by": "Filtern nach",
"Formula": "Formel",
"Get started with": "Erste Schritte mit",
"Greater than": "Größer als",
"Group by": "Gruppieren nach",
"Group this board by a select or status property.": "Gruppieren Sie dieses Board nach einer Auswahl- oder Statuseigenschaft.",
"Hide all": "Alle ausblenden",
"Hide group": "Gruppe ausblenden",
"In Progress": "In Bearbeitung",
"In progress": "In Bearbeitung",
"Include time": "Uhrzeit einschließen",
"Insert a kanban board on this page": "Fügen Sie ein Kanban-Board auf dieser Seite ein",
"Insert an inline base on this page": "Fügen Sie eine Inline-Base auf dieser Seite ein",
"Is": "Ist",
"Is after": "Ist nach",
"Is any of": "Ist eines von",
"Is before": "Ist vor",
"Is empty": "Ist leer",
"Is none of": "Ist keines von",
"Is not": "Ist nicht",
"Is not empty": "Ist nicht leer",
"Is on or after": "Ist am oder nach",
"Is on or before": "Ist am oder vor",
"Is within": "Ist innerhalb von",
"Keep editing": "Weiter bearbeiten",
"Last edited at": "Zuletzt bearbeitet am",
"Last edited by": "Zuletzt bearbeitet von",
"Less than": "Kleiner als",
"Link copied to clipboard": "Link in die Zwischenablage kopiert",
"Local": "Lokal",
"Long text": "Langer Text",
"Moved card to {{column}}": "Karte nach {{column}} verschoben",
"Moved column to {{column}}": "Spalte nach {{column}} verschoben",
"Multi-select": "Mehrfachauswahl",
"New row": "Neue Zeile",
"Next month": "Nächster Monat",
"Next week": "Nächste Woche",
"Next year": "Nächstes Jahr",
"No filters applied": "Keine Filter angewendet",
"No match": "Keine Übereinstimmung",
"No options for this property type": "Keine Optionen für diesen Eigenschaftstyp",
"No sorts applied": "Keine Sortierungen angewendet",
"No value": "Kein Wert",
"None": "Keine",
"Not started": "Nicht begonnen",
"Number": "Zahl",
"One month ago": "Vor einem Monat",
"One month from now": "In einem Monat",
"One week ago": "Vor einer Woche",
"One week from now": "In einer Woche",
"Open as page": "Als Seite öffnen",
"Options": "Optionen",
"Past month": "Letzter Monat",
"Past week": "Letzte Woche",
"Past year": "Letztes Jahr",
"Percent": "Prozent",
"Period, comma": "Punkt, Komma",
"Person": "Person",
"Please enter a valid email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"Progress": "Fortschritt",
"Property options": "Eigenschaftsoptionen",
"Relative": "Relativ",
"Rename property": "Eigenschaft umbenennen",
"Reset": "Zurücksetzen",
"Save for everyone": "Für alle speichern",
"Select": "Auswahl",
"Select a property": "Eigenschaft auswählen",
"Select all loaded rows": "Alle geladenen Zeilen auswählen",
"Show all": "Alle anzeigen",
"Sort": "Sortieren",
"Sort by": "Sortieren nach",
"Space, comma": "Leerzeichen, Komma",
"Space, period": "Leerzeichen, Punkt",
"This board has no grouping property yet.": "Dieses Board hat noch keine Gruppierungseigenschaft.",
"This month": "Diesen Monat",
"This year": "Dieses Jahr",
"Thousands and decimal separators": "Tausender- und Dezimaltrennzeichen",
"Time format": "Zeitformat",
"To Do": "Zu erledigen",
"Tomorrow": "Morgen",
"True": "Wahr",
"Unsaved changes": "Nicht gespeicherte Änderungen",
"Untitled base": "Unbenannte Base",
"URL": "URL",
"Value": "Wert",
"View updated for everyone": "Ansicht für alle aktualisiert",
"You have unsaved changes. Do you want to discard them?": "Sie haben nicht gespeicherte Änderungen. Möchten Sie sie verwerfen?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -14,6 +14,7 @@
"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.",
"Assigned to {{name}}": "Assigned to {{name}}",
"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",
@@ -22,6 +23,7 @@
"Can view": "Can view",
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
"Cancel": "Cancel",
"Card": "Card",
"Change email": "Change email",
"Change password": "Change password",
"Change photo": "Change photo",
@@ -30,6 +32,7 @@
"Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.",
"Confirm": "Confirm",
"Connect to {{name}} to update": "Connect to {{name}} to update",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Create": "Create",
@@ -41,6 +44,12 @@
"Dark": "Dark",
"Date": "Date",
"Delete": "Delete",
"Initiative": "Initiative",
"Last modified by {{name}}": "Last modified by {{name}}",
"Open in Slack": "Open in Slack",
"Paid": "Paid",
"Paste as": "Paste as",
"Project": "Project",
"Remove from page": "Remove from page",
"Base options": "Base options",
"Delete group": "Delete group",
@@ -150,6 +159,8 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
"replies": "replies",
"reply": "reply",
"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.",
@@ -184,6 +195,8 @@
"Invitation sent": "Invitation sent",
"Settings": "Settings",
"Setup workspace": "Setup workspace",
"show less": "show less",
"show more": "show more",
"Sign In": "Sign In",
"Sign Up": "Sign Up",
"Slug": "Slug",
@@ -206,7 +219,9 @@
"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",
"Toggle {{name}} integration": "Toggle {{name}} integration",
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
"Unassigned": "Unassigned",
"untitled": "untitled",
"Untitled": "Untitled",
"Updated successfully": "Updated successfully",
@@ -387,6 +402,8 @@
"Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.",
"Footnote": "Footnote",
"Insert a footnote reference.": "Insert a footnote reference.",
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
@@ -1153,5 +1170,152 @@
"Create property": "Create property",
"Hide properties": "Hide properties",
"Find a property type": "Find a property type",
"Properties": "Properties"
"Properties": "Properties",
"A property with this name already exists": "A property with this name already exists",
"Add card": "Add card",
"Add filter": "Add filter",
"Add option": "Add option",
"Add sort": "Add sort",
"Add view": "Add view",
"All data in this column will be lost.": "All data in this column will be lost.",
"Allow multiple people": "Allow multiple people",
"Alphabetize": "Alphabetize",
"Are you sure you want to delete": "Are you sure you want to delete",
"Ascending": "Ascending",
"Base deleted": "Base deleted",
"Base name": "Base name",
"Base table": "Base table",
"Card properties": "Card properties",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Cells longer than the Text limit will be truncated and the extra content permanently lost.",
"Checkbox": "Checkbox",
"Checked by default": "Checked by default",
"Choose a property": "Choose a property",
"Clear selection": "Clear selection",
"Column options": "Column options",
"Comma, period": "Comma, period",
"Complete": "Complete",
"Contains": "Contains",
"Copy link to view": "Copy link to view",
"Create a status property": "Create a status property",
"Created at": "Created at",
"Currency": "Currency",
"Decimal places": "Decimal places",
"Default value": "Default value",
"Delete property": "Delete property",
"Delete view": "Delete view",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Descending",
"Discard": "Discard",
"Doesn't contain": "Doesn't contain",
"Done": "Done",
"Edit formula": "Edit formula",
"Edit property": "Edit property",
"Export CSV": "Export CSV",
"Failed to load base": "Failed to load base",
"False": "False",
"File": "File",
"Filter and sort changes are visible only to you": "Filter and sort changes are visible only to you",
"Filter by": "Filter by",
"Formula": "Formula",
"Get started with": "Get started with",
"Greater than": "Greater than",
"Group by": "Group by",
"Group this board by a select or status property.": "Group this board by a select or status property.",
"Hide all": "Hide all",
"Hide group": "Hide group",
"In Progress": "In Progress",
"In progress": "In progress",
"Include time": "Include time",
"Insert a kanban board on this page": "Insert a kanban board on this page",
"Insert an inline base on this page": "Insert an inline base on this page",
"Is": "Is",
"Is after": "Is after",
"Is any of": "Is any of",
"Is before": "Is before",
"Is empty": "Is empty",
"Is none of": "Is none of",
"Is not": "Is not",
"Is not empty": "Is not empty",
"Is on or after": "Is on or after",
"Is on or before": "Is on or before",
"Is within": "Is within",
"Keep editing": "Keep editing",
"Last edited at": "Last edited at",
"Last edited by": "Last edited by",
"Less than": "Less than",
"Link copied to clipboard": "Link copied to clipboard",
"Local": "Local",
"Long text": "Long text",
"Moved card to {{column}}": "Moved card to {{column}}",
"Moved column to {{column}}": "Moved column to {{column}}",
"Multi-select": "Multi-select",
"New row": "New row",
"Next month": "Next month",
"Next week": "Next week",
"Next year": "Next year",
"No filters applied": "No filters applied",
"No match": "No match",
"No options for this property type": "No options for this property type",
"No sorts applied": "No sorts applied",
"No value": "No value",
"None": "None",
"Not started": "Not started",
"Number": "Number",
"One month ago": "One month ago",
"One month from now": "One month from now",
"One week ago": "One week ago",
"One week from now": "One week from now",
"Open as page": "Open as page",
"Options": "Options",
"Past month": "Past month",
"Past week": "Past week",
"Past year": "Past year",
"Percent": "Percent",
"Period, comma": "Period, comma",
"Person": "Person",
"Please enter a valid email": "Please enter a valid email",
"Progress": "Progress",
"Property options": "Property options",
"Relative": "Relative",
"Rename property": "Rename property",
"Reset": "Reset",
"Save for everyone": "Save for everyone",
"Select": "Select",
"Select a property": "Select a property",
"Select all loaded rows": "Select all loaded rows",
"Show all": "Show all",
"Sort": "Sort",
"Sort by": "Sort by",
"Space, comma": "Space, comma",
"Space, period": "Space, period",
"This board has no grouping property yet.": "This board has no grouping property yet.",
"This month": "This month",
"This year": "This year",
"Thousands and decimal separators": "Thousands and decimal separators",
"Time format": "Time format",
"To Do": "To Do",
"Tomorrow": "Tomorrow",
"True": "True",
"Unsaved changes": "Unsaved changes",
"Untitled base": "Untitled base",
"URL": "URL",
"Value": "Value",
"View updated for everyone": "View updated for everyone",
"You have unsaved changes. Do you want to discard them?": "You have unsaved changes. Do you want to discard them?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected",
"Compare": "Compare",
"Compare versions": "Compare versions",
"Select version from {{date}}": "Select version from {{date}}",
"Version actions for {{date}}": "Version actions for {{date}}",
"Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}",
"Exit compare": "Exit compare",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}"
}
@@ -990,6 +990,8 @@
"Are you sure you want to delete this template?": "¿Seguro que quieres eliminar esta plantilla?",
"Template scope updated": "Alcance de la plantilla actualizado",
"Choose which space this template belongs to": "Elige a qué espacio pertenece esta plantilla",
"Only workspace admins can create global templates.": "Solo los administradores del espacio de trabajo pueden crear plantillas globales.",
"You need edit access to this space to create templates.": "Necesitas acceso de edición a este espacio para crear plantillas.",
"Scope": "Alcance",
"Select scope": "Seleccionar alcance",
"Title": "Título",
@@ -1151,5 +1153,141 @@
"Create property": "Crear propiedad",
"Hide properties": "Ocultar propiedades",
"Find a property type": "Buscar un tipo de propiedad",
"Properties": "Propiedades"
"Properties": "Propiedades",
"A property with this name already exists": "Ya existe una propiedad con este nombre",
"Add card": "Agregar tarjeta",
"Add filter": "Agregar filtro",
"Add option": "Agregar opción",
"Add sort": "Agregar orden",
"Add view": "Agregar vista",
"All data in this column will be lost.": "Todos los datos de esta columna se perderán.",
"Allow multiple people": "Permitir varias personas",
"Alphabetize": "Ordenar alfabéticamente",
"Are you sure you want to delete": "¿Está seguro de que desea eliminar",
"Ascending": "Ascendente",
"Base deleted": "Base eliminada",
"Base name": "Nombre de la base",
"Base table": "Tabla base",
"Card properties": "Propiedades de la tarjeta",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Las celdas más largas que el límite de texto se truncarán y el contenido adicional se perderá permanentemente.",
"Checkbox": "Casilla de verificación",
"Checked by default": "Marcado por defecto",
"Choose a property": "Seleccione una propiedad",
"Clear selection": "Borrar selección",
"Column options": "Opciones de columna",
"Comma, period": "Coma, punto",
"Complete": "Completo",
"Contains": "Contiene",
"Copy link to view": "Copiar enlace a la vista",
"Create a status property": "Crear una propiedad de estado",
"Created at": "Creado el",
"Currency": "Moneda",
"Decimal places": "Decimales",
"Default value": "Valor predeterminado",
"Delete property": "Eliminar propiedad",
"Delete view": "Eliminar vista",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Descendente",
"Discard": "Descartar",
"Doesn't contain": "No contiene",
"Done": "Hecho",
"Edit formula": "Editar fórmula",
"Edit property": "Editar propiedad",
"Export CSV": "Exportar CSV",
"Failed to load base": "No se pudo cargar la base",
"False": "Falso",
"File": "Archivo",
"Filter and sort changes are visible only to you": "Los cambios de filtro y ordenación solo son visibles para usted",
"Filter by": "Filtrar por",
"Formula": "Fórmula",
"Get started with": "Comenzar con",
"Greater than": "Mayor que",
"Group by": "Agrupar por",
"Group this board by a select or status property.": "Agrupe este tablero por una propiedad de selección o estado.",
"Hide all": "Ocultar todo",
"Hide group": "Ocultar grupo",
"In Progress": "En curso",
"In progress": "En curso",
"Include time": "Incluir hora",
"Insert a kanban board on this page": "Insertar un tablero kanban en esta página",
"Insert an inline base on this page": "Insertar una base integrada en esta página",
"Is": "Es",
"Is after": "Es posterior a",
"Is any of": "Es cualquiera de",
"Is before": "Es anterior a",
"Is empty": "Está vacío",
"Is none of": "No es ninguno de",
"Is not": "No es",
"Is not empty": "No está vacío",
"Is on or after": "Es en o después de",
"Is on or before": "Es en o antes de",
"Is within": "Está dentro de",
"Keep editing": "Seguir editando",
"Last edited at": "Última edición el",
"Last edited by": "Última edición por",
"Less than": "Menor que",
"Link copied to clipboard": "Enlace copiado al portapapeles",
"Local": "Local",
"Long text": "Texto largo",
"Moved card to {{column}}": "Tarjeta movida a {{column}}",
"Moved column to {{column}}": "Columna movida a {{column}}",
"Multi-select": "Selección múltiple",
"New row": "Nueva fila",
"Next month": "Próximo mes",
"Next week": "Próxima semana",
"Next year": "Próximo año",
"No filters applied": "No hay filtros aplicados",
"No match": "Sin coincidencias",
"No options for this property type": "No hay opciones para este tipo de propiedad",
"No sorts applied": "No hay ordenaciones aplicadas",
"No value": "Sin valor",
"None": "Ninguno",
"Not started": "No iniciado",
"Number": "Número",
"One month ago": "Hace un mes",
"One month from now": "Dentro de un mes",
"One week ago": "Hace una semana",
"One week from now": "Dentro de una semana",
"Open as page": "Abrir como página",
"Options": "Opciones",
"Past month": "Último mes",
"Past week": "Última semana",
"Past year": "Último año",
"Percent": "Porcentaje",
"Period, comma": "Punto, coma",
"Person": "Persona",
"Please enter a valid email": "Por favor, introduzca un correo electrónico válido",
"Progress": "Progreso",
"Property options": "Opciones de propiedad",
"Relative": "Relativo",
"Rename property": "Renombrar propiedad",
"Reset": "Restablecer",
"Save for everyone": "Guardar para todos",
"Select": "Selección",
"Select a property": "Seleccionar una propiedad",
"Select all loaded rows": "Seleccionar todas las filas cargadas",
"Show all": "Mostrar todo",
"Sort": "Ordenar",
"Sort by": "Ordenar por",
"Space, comma": "Espacio, coma",
"Space, period": "Espacio, punto",
"This board has no grouping property yet.": "Este tablero aún no tiene una propiedad de agrupación.",
"This month": "Este mes",
"This year": "Este año",
"Thousands and decimal separators": "Separadores de miles y decimales",
"Time format": "Formato de hora",
"To Do": "Por hacer",
"Tomorrow": "Mañana",
"True": "Verdadero",
"Unsaved changes": "Cambios no guardados",
"Untitled base": "Base sin título",
"URL": "URL",
"Value": "Valor",
"View updated for everyone": "Vista actualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Tiene cambios no guardados. ¿Quiere descartarlos?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -990,6 +990,8 @@
"Are you sure you want to delete this template?": "Êtes-vous sûr de vouloir supprimer ce modèle ?",
"Template scope updated": "Portée du modèle mise à jour",
"Choose which space this template belongs to": "Choisissez à quel espace appartient ce modèle",
"Only workspace admins can create global templates.": "Seuls les administrateurs de lespace de travail peuvent créer des modèles globaux.",
"You need edit access to this space to create templates.": "Vous devez disposer dun accès en modification à cet espace pour créer des modèles.",
"Scope": "Portée",
"Select scope": "Sélectionner la portée",
"Title": "Titre",
@@ -1151,5 +1153,141 @@
"Create property": "Créer une propriété",
"Hide properties": "Masquer les propriétés",
"Find a property type": "Rechercher un type de propriété",
"Properties": "Propriétés"
"Properties": "Propriétés",
"A property with this name already exists": "Une propriété portant ce nom existe déjà",
"Add card": "Ajouter une carte",
"Add filter": "Ajouter un filtre",
"Add option": "Ajouter une option",
"Add sort": "Ajouter un tri",
"Add view": "Ajouter une vue",
"All data in this column will be lost.": "Toutes les données de cette colonne seront perdues.",
"Allow multiple people": "Autoriser plusieurs personnes",
"Alphabetize": "Trier par ordre alphabétique",
"Are you sure you want to delete": "Êtes-vous sûr de vouloir supprimer",
"Ascending": "Croissant",
"Base deleted": "Base supprimée",
"Base name": "Nom de la base",
"Base table": "Table de base",
"Card properties": "Propriétés de la carte",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Les cellules dépassant la limite de texte seront tronquées et le contenu supplémentaire sera définitivement perdu.",
"Checkbox": "Case à cocher",
"Checked by default": "Coché par défaut",
"Choose a property": "Choisir une propriété",
"Clear selection": "Effacer la sélection",
"Column options": "Options de la colonne",
"Comma, period": "Virgule, point",
"Complete": "Terminé",
"Contains": "Contient",
"Copy link to view": "Copier le lien vers la vue",
"Create a status property": "Créer une propriété de statut",
"Created at": "Créé le",
"Currency": "Devise",
"Decimal places": "Décimales",
"Default value": "Valeur par défaut",
"Delete property": "Supprimer la propriété",
"Delete view": "Supprimer la vue",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Décroissant",
"Discard": "Ignorer",
"Doesn't contain": "Ne contient pas",
"Done": "Terminé",
"Edit formula": "Modifier la formule",
"Edit property": "Modifier la propriété",
"Export CSV": "Exporter en CSV",
"Failed to load base": "Échec du chargement de la base",
"False": "Faux",
"File": "Fichier",
"Filter and sort changes are visible only to you": "Les modifications de filtre et de tri ne sont visibles que par vous",
"Filter by": "Filtrer par",
"Formula": "Formule",
"Get started with": "Commencer avec",
"Greater than": "Supérieur à",
"Group by": "Grouper par",
"Group this board by a select or status property.": "Groupez ce tableau par une propriété de sélection ou de statut.",
"Hide all": "Tout masquer",
"Hide group": "Masquer le groupe",
"In Progress": "En cours",
"In progress": "En cours",
"Include time": "Inclure l'heure",
"Insert a kanban board on this page": "Insérer un tableau kanban sur cette page",
"Insert an inline base on this page": "Insérer une base intégrée sur cette page",
"Is": "Est",
"Is after": "Est après",
"Is any of": "Est l'un de",
"Is before": "Est avant",
"Is empty": "Est vide",
"Is none of": "N'est aucun de",
"Is not": "N'est pas",
"Is not empty": "N'est pas vide",
"Is on or after": "Est le ou après le",
"Is on or before": "Est le ou avant le",
"Is within": "Est dans la plage",
"Keep editing": "Continuer la modification",
"Last edited at": "Dernière modification le",
"Last edited by": "Dernière modification par",
"Less than": "Inférieur à",
"Link copied to clipboard": "Lien copié dans le presse-papiers",
"Local": "Local",
"Long text": "Texte long",
"Moved card to {{column}}": "Carte déplacée vers {{column}}",
"Moved column to {{column}}": "Colonne déplacée vers {{column}}",
"Multi-select": "Sélection multiple",
"New row": "Nouvelle ligne",
"Next month": "Le mois prochain",
"Next week": "La semaine prochaine",
"Next year": "L'année prochaine",
"No filters applied": "Aucun filtre appliqué",
"No match": "Aucune correspondance",
"No options for this property type": "Aucune option pour ce type de propriété",
"No sorts applied": "Aucun tri appliqué",
"No value": "Aucune valeur",
"None": "Aucun",
"Not started": "Non commencé",
"Number": "Nombre",
"One month ago": "Il y a un mois",
"One month from now": "Dans un mois",
"One week ago": "Il y a une semaine",
"One week from now": "Dans une semaine",
"Open as page": "Ouvrir comme page",
"Options": "Options",
"Past month": "Le mois dernier",
"Past week": "La semaine dernière",
"Past year": "L'année dernière",
"Percent": "Pourcentage",
"Period, comma": "Point, virgule",
"Person": "Personne",
"Please enter a valid email": "Veuillez entrer une adresse e-mail valide",
"Progress": "Progression",
"Property options": "Options de la propriété",
"Relative": "Relatif",
"Rename property": "Renommer la propriété",
"Reset": "Réinitialiser",
"Save for everyone": "Enregistrer pour tout le monde",
"Select": "Sélection",
"Select a property": "Sélectionner une propriété",
"Select all loaded rows": "Sélectionner toutes les lignes chargées",
"Show all": "Tout afficher",
"Sort": "Trier",
"Sort by": "Trier par",
"Space, comma": "Espace, virgule",
"Space, period": "Espace, point",
"This board has no grouping property yet.": "Ce tableau n'a pas encore de propriété de groupement.",
"This month": "Ce mois-ci",
"This year": "Cette année",
"Thousands and decimal separators": "Séparateurs des milliers et des décimales",
"Time format": "Format de l'heure",
"To Do": "À faire",
"Tomorrow": "Demain",
"True": "Vrai",
"Unsaved changes": "Modifications non enregistrées",
"Untitled base": "Base sans titre",
"URL": "URL",
"Value": "Valeur",
"View updated for everyone": "Vue mise à jour pour tout le monde",
"You have unsaved changes. Do you want to discard them?": "Vous avez des modifications non enregistrées. Voulez-vous les ignorer ?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -990,6 +990,8 @@
"Are you sure you want to delete this template?": "Sei sicuro di voler eliminare questo modello?",
"Template scope updated": "Ambito del modello aggiornato",
"Choose which space this template belongs to": "Scegli a quale spazio appartiene questo modello",
"Only workspace admins can create global templates.": "Solo gli amministratori dell'area di lavoro possono creare modelli globali.",
"You need edit access to this space to create templates.": "Hai bisogno dell'accesso in modifica a questo spazio per creare modelli.",
"Scope": "Ambito",
"Select scope": "Seleziona ambito",
"Title": "Titolo",
@@ -1151,5 +1153,141 @@
"Create property": "Crea proprietà",
"Hide properties": "Nascondi proprietà",
"Find a property type": "Trova un tipo di proprietà",
"Properties": "Proprietà"
"Properties": "Proprietà",
"A property with this name already exists": "Esiste già una proprietà con questo nome",
"Add card": "Aggiungi scheda",
"Add filter": "Aggiungi filtro",
"Add option": "Aggiungi opzione",
"Add sort": "Aggiungi ordinamento",
"Add view": "Aggiungi vista",
"All data in this column will be lost.": "Tutti i dati in questa colonna andranno persi.",
"Allow multiple people": "Consenti più persone",
"Alphabetize": "Ordina alfabeticamente",
"Are you sure you want to delete": "Sei sicuro di voler eliminare",
"Ascending": "Crescente",
"Base deleted": "Base eliminata",
"Base name": "Nome della base",
"Base table": "Tabella della base",
"Card properties": "Proprietà della scheda",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Le celle più lunghe del limite del testo verranno troncate e il contenuto aggiuntivo andrà perso in modo permanente.",
"Checkbox": "Casella di controllo",
"Checked by default": "Selezionata per impostazione predefinita",
"Choose a property": "Scegli una proprietà",
"Clear selection": "Cancella selezione",
"Column options": "Opzioni della colonna",
"Comma, period": "Virgola, punto",
"Complete": "Completato",
"Contains": "Contiene",
"Copy link to view": "Copia link alla vista",
"Create a status property": "Crea una proprietà di stato",
"Created at": "Creato il",
"Currency": "Valuta",
"Decimal places": "Posizioni decimali",
"Default value": "Valore predefinito",
"Delete property": "Elimina proprietà",
"Delete view": "Elimina vista",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Decrescente",
"Discard": "Ignora",
"Doesn't contain": "Non contiene",
"Done": "Fatto",
"Edit formula": "Modifica formula",
"Edit property": "Modifica proprietà",
"Export CSV": "Esporta CSV",
"Failed to load base": "Impossibile caricare la base",
"False": "Falso",
"File": "File",
"Filter and sort changes are visible only to you": "Le modifiche a filtri e ordinamenti sono visibili solo a te",
"Filter by": "Filtra per",
"Formula": "Formula",
"Get started with": "Inizia con",
"Greater than": "Maggiore di",
"Group by": "Raggruppa per",
"Group this board by a select or status property.": "Raggruppa questa bacheca per una proprietà di selezione o di stato.",
"Hide all": "Nascondi tutto",
"Hide group": "Nascondi gruppo",
"In Progress": "In corso",
"In progress": "In corso",
"Include time": "Includi ora",
"Insert a kanban board on this page": "Inserisci una bacheca kanban in questa pagina",
"Insert an inline base on this page": "Inserisci una base in linea in questa pagina",
"Is": "È",
"Is after": "È dopo",
"Is any of": "È uno tra",
"Is before": "È prima",
"Is empty": "È vuoto",
"Is none of": "Non è nessuno di",
"Is not": "Non è",
"Is not empty": "Non è vuoto",
"Is on or after": "È il o dopo il",
"Is on or before": "È il o prima del",
"Is within": "È entro",
"Keep editing": "Continua a modificare",
"Last edited at": "Ultima modifica il",
"Last edited by": "Ultima modifica di",
"Less than": "Minore di",
"Link copied to clipboard": "Link copiato negli appunti",
"Local": "Locale",
"Long text": "Testo lungo",
"Moved card to {{column}}": "Scheda spostata in {{column}}",
"Moved column to {{column}}": "Colonna spostata in {{column}}",
"Multi-select": "Selezione multipla",
"New row": "Nuova riga",
"Next month": "Prossimo mese",
"Next week": "Prossima settimana",
"Next year": "Prossimo anno",
"No filters applied": "Nessun filtro applicato",
"No match": "Nessuna corrispondenza",
"No options for this property type": "Nessuna opzione per questo tipo di proprietà",
"No sorts applied": "Nessun ordinamento applicato",
"No value": "Nessun valore",
"None": "Nessuno",
"Not started": "Non iniziato",
"Number": "Numero",
"One month ago": "Un mese fa",
"One month from now": "Tra un mese",
"One week ago": "Una settimana fa",
"One week from now": "Tra una settimana",
"Open as page": "Apri come pagina",
"Options": "Opzioni",
"Past month": "Mese scorso",
"Past week": "Settimana scorsa",
"Past year": "Anno scorso",
"Percent": "Percentuale",
"Period, comma": "Punto, virgola",
"Person": "Persona",
"Please enter a valid email": "Inserisci un indirizzo email valido",
"Progress": "Avanzamento",
"Property options": "Opzioni della proprietà",
"Relative": "Relativo",
"Rename property": "Rinomina proprietà",
"Reset": "Reimposta",
"Save for everyone": "Salva per tutti",
"Select": "Seleziona",
"Select a property": "Seleziona una proprietà",
"Select all loaded rows": "Seleziona tutte le righe caricate",
"Show all": "Mostra tutto",
"Sort": "Ordina",
"Sort by": "Ordina per",
"Space, comma": "Spazio, virgola",
"Space, period": "Spazio, punto",
"This board has no grouping property yet.": "Questa bacheca non ha ancora una proprietà di raggruppamento.",
"This month": "Questo mese",
"This year": "Quest'anno",
"Thousands and decimal separators": "Separatori delle migliaia e dei decimali",
"Time format": "Formato ora",
"To Do": "Da fare",
"Tomorrow": "Domani",
"True": "Vero",
"Unsaved changes": "Modifiche non salvate",
"Untitled base": "Base senza titolo",
"URL": "URL",
"Value": "Valore",
"View updated for everyone": "Vista aggiornata per tutti",
"You have unsaved changes. Do you want to discard them?": "Hai modifiche non salvate. Vuoi ignorarle?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -990,6 +990,8 @@
"Are you sure you want to delete this template?": "このテンプレートを削除してもよろしいですか?",
"Template scope updated": "テンプレートのスコープを更新しました",
"Choose which space this template belongs to": "このテンプレートを所属させるスペースを選択",
"Only workspace admins can create global templates.": "グローバルテンプレートを作成できるのは、ワークスペース管理者のみです。",
"You need edit access to this space to create templates.": "テンプレートを作成するには、このスペースへの編集アクセス権が必要です。",
"Scope": "スコープ",
"Select scope": "スコープを選択",
"Title": "タイトル",
@@ -1151,5 +1153,141 @@
"Create property": "プロパティを作成",
"Hide properties": "プロパティを非表示",
"Find a property type": "プロパティの種類を検索",
"Properties": "プロパティ"
"Properties": "プロパティ",
"A property with this name already exists": "この名前のプロパティはすでに存在します",
"Add card": "カードを追加",
"Add filter": "フィルターを追加",
"Add option": "オプションを追加",
"Add sort": "並べ替えを追加",
"Add view": "ビューを追加",
"All data in this column will be lost.": "この列内のすべてのデータは失われます。",
"Allow multiple people": "複数のユーザーを許可",
"Alphabetize": "アルファベット順",
"Are you sure you want to delete": "削除してもよろしいですか",
"Ascending": "昇順",
"Base deleted": "ベースを削除しました",
"Base name": "ベース名",
"Base table": "ベーステーブル",
"Card properties": "カードのプロパティ",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "テキストの制限を超える長さのセルは切り詰められ、超過分の内容は完全に失われます。",
"Checkbox": "チェックボックス",
"Checked by default": "デフォルトでチェックを入れる",
"Choose a property": "プロパティを選択",
"Clear selection": "選択をクリア",
"Column options": "列のオプション",
"Comma, period": "カンマ、小数点",
"Complete": "完了",
"Contains": "含む",
"Copy link to view": "ビューへのリンクをコピー",
"Create a status property": "ステータスプロパティを作成",
"Created at": "作成日時",
"Currency": "通貨",
"Decimal places": "小数点以下の桁数",
"Default value": "デフォルト値",
"Delete property": "プロパティを削除",
"Delete view": "ビューを削除",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "降順",
"Discard": "破棄",
"Doesn't contain": "含まない",
"Done": "完了",
"Edit formula": "数式を編集",
"Edit property": "プロパティを編集",
"Export CSV": "CSVをエクスポート",
"Failed to load base": "ベースの読み込みに失敗しました",
"False": "偽",
"File": "ファイル",
"Filter and sort changes are visible only to you": "フィルターと並べ替えの変更は自分にのみ表示されます",
"Filter by": "条件",
"Formula": "数式",
"Get started with": "次から始める",
"Greater than": "より大きい",
"Group by": "グループ化",
"Group this board by a select or status property.": "このボードをセレクトまたはステータスのプロパティでグループ化します。",
"Hide all": "すべて非表示",
"Hide group": "グループを非表示",
"In Progress": "進行中",
"In progress": "進行中",
"Include time": "時刻を含める",
"Insert a kanban board on this page": "このページにカンバンボードを挿入",
"Insert an inline base on this page": "このページにインラインベースを挿入",
"Is": "次に等しい",
"Is after": "次より後",
"Is any of": "次のいずれか",
"Is before": "次より前",
"Is empty": "空である",
"Is none of": "次のいずれでもない",
"Is not": "次に等しくない",
"Is not empty": "空ではない",
"Is on or after": "次以降",
"Is on or before": "次以前",
"Is within": "次の期間内",
"Keep editing": "編集を続ける",
"Last edited at": "最終編集日時",
"Last edited by": "最終編集者",
"Less than": "より小さい",
"Link copied to clipboard": "リンクをクリップボードにコピーしました",
"Local": "ローカル",
"Long text": "長いテキスト",
"Moved card to {{column}}": "カードを{{column}}に移動しました",
"Moved column to {{column}}": "列を{{column}}に移動しました",
"Multi-select": "複数選択",
"New row": "新規行",
"Next month": "来月",
"Next week": "来週",
"Next year": "来年",
"No filters applied": "フィルターは適用されていません",
"No match": "一致なし",
"No options for this property type": "このプロパティタイプにはオプションがありません",
"No sorts applied": "並べ替えは適用されていません",
"No value": "値なし",
"None": "なし",
"Not started": "未着手",
"Number": "数値",
"One month ago": "1か月前",
"One month from now": "1か月後",
"One week ago": "1週間前",
"One week from now": "1週間後",
"Open as page": "ページとして開く",
"Options": "オプション",
"Past month": "先月",
"Past week": "先週",
"Past year": "昨年",
"Percent": "パーセント",
"Period, comma": "小数点、カンマ",
"Person": "ユーザー",
"Please enter a valid email": "有効なメールアドレスを入力してください",
"Progress": "進捗",
"Property options": "プロパティのオプション",
"Relative": "相対",
"Rename property": "プロパティ名を変更",
"Reset": "リセット",
"Save for everyone": "全員に保存",
"Select": "選択",
"Select a property": "プロパティを選択",
"Select all loaded rows": "読み込まれているすべての行を選択",
"Show all": "すべて表示",
"Sort": "並べ替え",
"Sort by": "並べ替え条件",
"Space, comma": "スペース、カンマ",
"Space, period": "スペース、小数点",
"This board has no grouping property yet.": "このボードにはまだグループ化プロパティがありません。",
"This month": "今月",
"This year": "今年",
"Thousands and decimal separators": "桁区切りと小数点区切り",
"Time format": "時刻形式",
"To Do": "To Do",
"Tomorrow": "明日",
"True": "真",
"Unsaved changes": "未保存の変更",
"Untitled base": "無題のベース",
"URL": "URL",
"Value": "値",
"View updated for everyone": "ビューが全員向けに更新されました",
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -990,6 +990,8 @@
"Are you sure you want to delete this template?": "이 템플릿을 삭제하시겠습니까?",
"Template scope updated": "템플릿 범위가 업데이트되었습니다",
"Choose which space this template belongs to": "이 템플릿이 속할 스페이스를 선택하세요",
"Only workspace admins can create global templates.": "전역 템플릿은 워크스페이스 관리자만 만들 수 있습니다.",
"You need edit access to this space to create templates.": "템플릿을 만들려면 이 스페이스에 대한 편집 권한이 있어야 합니다.",
"Scope": "범위",
"Select scope": "범위 선택",
"Title": "제목",
@@ -1151,5 +1153,141 @@
"Create property": "속성 만들기",
"Hide properties": "속성 숨기기",
"Find a property type": "속성 유형 찾기",
"Properties": "속성"
"Properties": "속성",
"A property with this name already exists": "이 이름의 속성이 이미 있습니다",
"Add card": "카드 추가",
"Add filter": "필터 추가",
"Add option": "옵션 추가",
"Add sort": "정렬 추가",
"Add view": "보기 추가",
"All data in this column will be lost.": "이 열의 모든 데이터가 손실됩니다.",
"Allow multiple people": "여러 사람 허용",
"Alphabetize": "가나다순",
"Are you sure you want to delete": "삭제하시겠습니까",
"Ascending": "오름차순",
"Base deleted": "베이스가 삭제됨",
"Base name": "베이스 이름",
"Base table": "베이스 테이블",
"Card properties": "카드 속성",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "텍스트 제한보다 긴 셀은 잘리며, 초과된 콘텐츠는 영구적으로 손실됩니다.",
"Checkbox": "체크박스",
"Checked by default": "기본적으로 선택됨",
"Choose a property": "속성 선택",
"Clear selection": "선택 지우기",
"Column options": "열 옵션",
"Comma, period": "쉼표, 마침표",
"Complete": "완료",
"Contains": "포함",
"Copy link to view": "보기 링크 복사",
"Create a status property": "상태 속성 만들기",
"Created at": "작성 시간",
"Currency": "통화",
"Decimal places": "소수 자릿수",
"Default value": "기본값",
"Delete property": "속성 삭제",
"Delete view": "보기 삭제",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "내림차순",
"Discard": "삭제",
"Doesn't contain": "포함하지 않음",
"Done": "완료",
"Edit formula": "수식 편집",
"Edit property": "속성 편집",
"Export CSV": "CSV 내보내기",
"Failed to load base": "베이스를 불러오지 못했습니다",
"False": "거짓",
"File": "파일",
"Filter and sort changes are visible only to you": "필터 및 정렬 변경 사항은 나에게만 표시됩니다",
"Filter by": "필터 기준",
"Formula": "수식",
"Get started with": "시작하기",
"Greater than": "보다 큼",
"Group by": "그룹화 기준",
"Group this board by a select or status property.": "선택 또는 상태 속성으로 이 보드를 그룹화하세요.",
"Hide all": "모두 숨기기",
"Hide group": "그룹 숨기기",
"In Progress": "진행 중",
"In progress": "진행 중",
"Include time": "시간 포함",
"Insert a kanban board on this page": "이 페이지에 칸반 보드 삽입",
"Insert an inline base on this page": "이 페이지에 인라인 베이스 삽입",
"Is": "같음",
"Is after": "이후",
"Is any of": "다음 중 하나와 같음",
"Is before": "이전",
"Is empty": "비어 있음",
"Is none of": "다음 중 어느 것과도 같지 않음",
"Is not": "같지 않음",
"Is not empty": "비어 있지 않음",
"Is on or after": "해당 날짜 또는 이후",
"Is on or before": "해당 날짜 또는 이전",
"Is within": "다음 범위 내",
"Keep editing": "계속 편집",
"Last edited at": "마지막 편집 시간",
"Last edited by": "마지막 편집자",
"Less than": "보다 작음",
"Link copied to clipboard": "링크가 클립보드에 복사됨",
"Local": "로컬",
"Long text": "긴 텍스트",
"Moved card to {{column}}": "카드를 {{column}}(으)로 이동함",
"Moved column to {{column}}": "열을 {{column}}(으)로 이동함",
"Multi-select": "다중 선택",
"New row": "새 행",
"Next month": "다음 달",
"Next week": "다음 주",
"Next year": "내년",
"No filters applied": "적용된 필터 없음",
"No match": "일치하는 항목 없음",
"No options for this property type": "이 속성 유형에는 옵션이 없습니다",
"No sorts applied": "적용된 정렬 없음",
"No value": "값 없음",
"None": "없음",
"Not started": "시작 전",
"Number": "숫자",
"One month ago": "한 달 전",
"One month from now": "한 달 후",
"One week ago": "일주일 전",
"One week from now": "일주일 후",
"Open as page": "페이지로 열기",
"Options": "옵션",
"Past month": "지난달",
"Past week": "지난주",
"Past year": "작년",
"Percent": "백분율",
"Period, comma": "마침표, 쉼표",
"Person": "사람",
"Please enter a valid email": "유효한 이메일을 입력하세요",
"Progress": "진행률",
"Property options": "속성 옵션",
"Relative": "상대값",
"Rename property": "속성 이름 변경",
"Reset": "재설정",
"Save for everyone": "모든 사용자에게 저장",
"Select": "선택",
"Select a property": "속성 선택",
"Select all loaded rows": "불러온 모든 행 선택",
"Show all": "모두 보기",
"Sort": "정렬",
"Sort by": "정렬 기준",
"Space, comma": "공백, 쉼표",
"Space, period": "공백, 마침표",
"This board has no grouping property yet.": "이 보드에는 아직 그룹화 속성이 없습니다.",
"This month": "이번 달",
"This year": "올해",
"Thousands and decimal separators": "천 단위 및 소수 구분 기호",
"Time format": "시간 형식",
"To Do": "할 일",
"Tomorrow": "내일",
"True": "참",
"Unsaved changes": "저장되지 않은 변경 사항",
"Untitled base": "제목 없는 베이스",
"URL": "URL",
"Value": "값",
"View updated for everyone": "보기가 모두에게 업데이트되었습니다",
"You have unsaved changes. Do you want to discard them?": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -990,6 +990,8 @@
"Are you sure you want to delete this template?": "Weet je zeker dat je dit sjabloon wilt verwijderen?",
"Template scope updated": "Bereik van sjabloon bijgewerkt",
"Choose which space this template belongs to": "Kies bij welke werkruimte dit sjabloon hoort",
"Only workspace admins can create global templates.": "Alleen workspace-beheerders kunnen globale sjablonen maken.",
"You need edit access to this space to create templates.": "Je hebt bewerkingstoegang tot deze ruimte nodig om sjablonen te maken.",
"Scope": "Bereik",
"Select scope": "Bereik selecteren",
"Title": "Titel",
@@ -1151,5 +1153,141 @@
"Create property": "Eigenschap aanmaken",
"Hide properties": "Eigenschappen verbergen",
"Find a property type": "Een eigenschapstype zoeken",
"Properties": "Eigenschappen"
"Properties": "Eigenschappen",
"A property with this name already exists": "Er bestaat al een eigenschap met deze naam",
"Add card": "Kaart toevoegen",
"Add filter": "Filter toevoegen",
"Add option": "Optie toevoegen",
"Add sort": "Sortering toevoegen",
"Add view": "Weergave toevoegen",
"All data in this column will be lost.": "Alle gegevens in deze kolom gaan verloren.",
"Allow multiple people": "Meerdere personen toestaan",
"Alphabetize": "Alfabetisch sorteren",
"Are you sure you want to delete": "Weet je zeker dat je wilt verwijderen",
"Ascending": "Oplopend",
"Base deleted": "Base verwijderd",
"Base name": "Basenaam",
"Base table": "Basistabel",
"Card properties": "Kaarteigenschappen",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Cellen die langer zijn dan de tekstlimiet worden afgekapt en de extra inhoud gaat permanent verloren.",
"Checkbox": "Selectievakje",
"Checked by default": "Standaard aangevinkt",
"Choose a property": "Kies een eigenschap",
"Clear selection": "Selectie wissen",
"Column options": "Kolomopties",
"Comma, period": "Komma, punt",
"Complete": "Voltooid",
"Contains": "Bevat",
"Copy link to view": "Link naar weergave kopiëren",
"Create a status property": "Een statuseigenschap maken",
"Created at": "Aangemaakt op",
"Currency": "Valuta",
"Decimal places": "Decimalen",
"Default value": "Standaardwaarde",
"Delete property": "Eigenschap verwijderen",
"Delete view": "Weergave verwijderen",
"Delete {{count}} rows?_one": "1 rij verwijderen?",
"Delete {{count}} rows?_other": "{{count}} rijen verwijderen?",
"Descending": "Aflopend",
"Discard": "Negeren",
"Doesn't contain": "Bevat niet",
"Done": "Klaar",
"Edit formula": "Formule bewerken",
"Edit property": "Eigenschap bewerken",
"Export CSV": "CSV exporteren",
"Failed to load base": "Base laden mislukt",
"False": "Onwaar",
"File": "Bestand",
"Filter and sort changes are visible only to you": "Wijzigingen in filters en sortering zijn alleen zichtbaar voor jou",
"Filter by": "Filteren op",
"Formula": "Formule",
"Get started with": "Aan de slag met",
"Greater than": "Groter dan",
"Group by": "Groeperen op",
"Group this board by a select or status property.": "Groepeer dit bord op een selectie- of statuseigenschap.",
"Hide all": "Alles verbergen",
"Hide group": "Groep verbergen",
"In Progress": "In uitvoering",
"In progress": "In uitvoering",
"Include time": "Tijd opnemen",
"Insert a kanban board on this page": "Voeg een kanbanbord in op deze pagina",
"Insert an inline base on this page": "Voeg een inline base in op deze pagina",
"Is": "Is",
"Is after": "Is na",
"Is any of": "Is een van",
"Is before": "Is vóór",
"Is empty": "Is leeg",
"Is none of": "Is geen van",
"Is not": "Is niet",
"Is not empty": "Is niet leeg",
"Is on or after": "Is op of na",
"Is on or before": "Is op of vóór",
"Is within": "Is binnen",
"Keep editing": "Doorgaan met bewerken",
"Last edited at": "Laatst bewerkt op",
"Last edited by": "Laatst bewerkt door",
"Less than": "Kleiner dan",
"Link copied to clipboard": "Link gekopieerd naar klembord",
"Local": "Lokaal",
"Long text": "Lange tekst",
"Moved card to {{column}}": "Kaart verplaatst naar {{column}}",
"Moved column to {{column}}": "Kolom verplaatst naar {{column}}",
"Multi-select": "Meervoudige selectie",
"New row": "Nieuwe rij",
"Next month": "Volgende maand",
"Next week": "Volgende week",
"Next year": "Volgend jaar",
"No filters applied": "Geen filters toegepast",
"No match": "Geen overeenkomst",
"No options for this property type": "Geen opties voor dit eigenschapstype",
"No sorts applied": "Geen sorteringen toegepast",
"No value": "Geen waarde",
"None": "Geen",
"Not started": "Niet gestart",
"Number": "Getal",
"One month ago": "Een maand geleden",
"One month from now": "Over een maand",
"One week ago": "Een week geleden",
"One week from now": "Over een week",
"Open as page": "Openen als pagina",
"Options": "Opties",
"Past month": "Afgelopen maand",
"Past week": "Afgelopen week",
"Past year": "Afgelopen jaar",
"Percent": "Procent",
"Period, comma": "Punt, komma",
"Person": "Persoon",
"Please enter a valid email": "Voer een geldig e-mailadres in",
"Progress": "Voortgang",
"Property options": "Eigenschapopties",
"Relative": "Relatief",
"Rename property": "Eigenschap hernoemen",
"Reset": "Opnieuw instellen",
"Save for everyone": "Opslaan voor iedereen",
"Select": "Selecteren",
"Select a property": "Selecteer een eigenschap",
"Select all loaded rows": "Alle geladen rijen selecteren",
"Show all": "Alles tonen",
"Sort": "Sorteren",
"Sort by": "Sorteren op",
"Space, comma": "Spatie, komma",
"Space, period": "Spatie, punt",
"This board has no grouping property yet.": "Dit bord heeft nog geen groeperingseigenschap.",
"This month": "Deze maand",
"This year": "Dit jaar",
"Thousands and decimal separators": "Scheidingstekens voor duizendtallen en decimalen",
"Time format": "Tijdnotatie",
"To Do": "Te doen",
"Tomorrow": "Morgen",
"True": "Waar",
"Unsaved changes": "Niet-opgeslagen wijzigingen",
"Untitled base": "Base zonder titel",
"URL": "URL",
"Value": "Waarde",
"View updated for everyone": "Weergave bijgewerkt voor iedereen",
"You have unsaved changes. Do you want to discard them?": "Je hebt niet-opgeslagen wijzigingen. Wil je deze negeren?",
"{{count}} rows deleted_one": "1 rij verwijderd",
"{{count}} rows deleted_other": "{{count}} rijen verwijderd",
"{{count}} selected_one": "1 geselecteerd",
"{{count}} selected_other": "{{count}} geselecteerd"
}
@@ -990,6 +990,8 @@
"Are you sure you want to delete this template?": "Tem certeza de que deseja excluir este modelo?",
"Template scope updated": "Escopo do modelo atualizado",
"Choose which space this template belongs to": "Escolha a qual espaço este modelo pertence",
"Only workspace admins can create global templates.": "Somente administradores do espaço de trabalho podem criar modelos globais.",
"You need edit access to this space to create templates.": "Você precisa de acesso de edição a este espaço para criar modelos.",
"Scope": "Escopo",
"Select scope": "Selecionar escopo",
"Title": "Título",
@@ -1151,5 +1153,141 @@
"Create property": "Criar propriedade",
"Hide properties": "Ocultar propriedades",
"Find a property type": "Encontrar um tipo de propriedade",
"Properties": "Propriedades"
"Properties": "Propriedades",
"A property with this name already exists": "Já existe uma propriedade com este nome",
"Add card": "Adicionar cartão",
"Add filter": "Adicionar filtro",
"Add option": "Adicionar opção",
"Add sort": "Adicionar ordenação",
"Add view": "Adicionar visualização",
"All data in this column will be lost.": "Todos os dados nesta coluna serão perdidos.",
"Allow multiple people": "Permitir várias pessoas",
"Alphabetize": "Ordenar alfabeticamente",
"Are you sure you want to delete": "Tem certeza de que deseja excluir",
"Ascending": "Crescente",
"Base deleted": "Base excluída",
"Base name": "Nome da base",
"Base table": "Tabela da base",
"Card properties": "Propriedades do cartão",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Células maiores do que o limite de Texto serão truncadas e o conteúdo extra será perdido permanentemente.",
"Checkbox": "Caixa de seleção",
"Checked by default": "Marcado por padrão",
"Choose a property": "Escolha uma propriedade",
"Clear selection": "Limpar seleção",
"Column options": "Opções da coluna",
"Comma, period": "Vírgula, ponto",
"Complete": "Concluído",
"Contains": "Contém",
"Copy link to view": "Copiar link da visualização",
"Create a status property": "Criar uma propriedade de status",
"Created at": "Criado em",
"Currency": "Moeda",
"Decimal places": "Casas decimais",
"Default value": "Valor padrão",
"Delete property": "Excluir propriedade",
"Delete view": "Excluir visualização",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Decrescente",
"Discard": "Descartar",
"Doesn't contain": "Não contém",
"Done": "Concluído",
"Edit formula": "Editar fórmula",
"Edit property": "Editar propriedade",
"Export CSV": "Exportar CSV",
"Failed to load base": "Falha ao carregar a base",
"False": "Falso",
"File": "Arquivo",
"Filter and sort changes are visible only to you": "As alterações de filtro e ordenação são visíveis somente para você",
"Filter by": "Filtrar por",
"Formula": "Fórmula",
"Get started with": "Começar com",
"Greater than": "Maior que",
"Group by": "Agrupar por",
"Group this board by a select or status property.": "Agrupe este quadro por uma propriedade de seleção ou status.",
"Hide all": "Ocultar tudo",
"Hide group": "Ocultar grupo",
"In Progress": "Em andamento",
"In progress": "Em andamento",
"Include time": "Incluir hora",
"Insert a kanban board on this page": "Inserir um quadro kanban nesta página",
"Insert an inline base on this page": "Inserir uma base inline nesta página",
"Is": "É",
"Is after": "É depois de",
"Is any of": "É qualquer um de",
"Is before": "É antes de",
"Is empty": "Está vazio",
"Is none of": "Não é nenhum de",
"Is not": "Não é",
"Is not empty": "Não está vazio",
"Is on or after": "É em ou depois de",
"Is on or before": "É em ou antes de",
"Is within": "Está dentro de",
"Keep editing": "Continuar editando",
"Last edited at": "Última edição em",
"Last edited by": "Última edição por",
"Less than": "Menor que",
"Link copied to clipboard": "Link copiado para a área de transferência",
"Local": "Local",
"Long text": "Texto longo",
"Moved card to {{column}}": "Cartão movido para {{column}}",
"Moved column to {{column}}": "Coluna movida para {{column}}",
"Multi-select": "Seleção múltipla",
"New row": "Nova linha",
"Next month": "Próximo mês",
"Next week": "Próxima semana",
"Next year": "Próximo ano",
"No filters applied": "Nenhum filtro aplicado",
"No match": "Sem correspondência",
"No options for this property type": "Sem opções para este tipo de propriedade",
"No sorts applied": "Nenhuma ordenação aplicada",
"No value": "Sem valor",
"None": "Nenhum",
"Not started": "Não iniciado",
"Number": "Número",
"One month ago": "Há um mês",
"One month from now": "Daqui a um mês",
"One week ago": "Há uma semana",
"One week from now": "Daqui a uma semana",
"Open as page": "Abrir como página",
"Options": "Opções",
"Past month": "Mês passado",
"Past week": "Semana passada",
"Past year": "Ano passado",
"Percent": "Porcentagem",
"Period, comma": "Ponto, vírgula",
"Person": "Pessoa",
"Please enter a valid email": "Por favor, insira um e-mail válido",
"Progress": "Progresso",
"Property options": "Opções da propriedade",
"Relative": "Relativo",
"Rename property": "Renomear propriedade",
"Reset": "Redefinir",
"Save for everyone": "Salvar para todos",
"Select": "Seleção",
"Select a property": "Selecione uma propriedade",
"Select all loaded rows": "Selecionar todas as linhas carregadas",
"Show all": "Mostrar tudo",
"Sort": "Ordenar",
"Sort by": "Ordenar por",
"Space, comma": "Espaço, vírgula",
"Space, period": "Espaço, ponto",
"This board has no grouping property yet.": "Este quadro ainda não tem uma propriedade de agrupamento.",
"This month": "Este mês",
"This year": "Este ano",
"Thousands and decimal separators": "Separadores de milhares e decimais",
"Time format": "Formato de hora",
"To Do": "A fazer",
"Tomorrow": "Amanhã",
"True": "Verdadeiro",
"Unsaved changes": "Alterações não salvas",
"Untitled base": "Base sem título",
"URL": "URL",
"Value": "Valor",
"View updated for everyone": "Visualização atualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Você tem alterações não salvas. Deseja descartá-las?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -990,6 +990,8 @@
"Are you sure you want to delete this template?": "Вы уверены, что хотите удалить этот шаблон?",
"Template scope updated": "Область действия шаблона обновлена",
"Choose which space this template belongs to": "Выберите, к какому пространству относится этот шаблон",
"Only workspace admins can create global templates.": "Только администраторы рабочего пространства могут создавать глобальные шаблоны.",
"You need edit access to this space to create templates.": "Чтобы создавать шаблоны, вам нужен доступ на редактирование этого пространства.",
"Scope": "Область действия",
"Select scope": "Выберите область действия",
"Title": "Заголовок",
@@ -1151,5 +1153,141 @@
"Create property": "Создать свойство",
"Hide properties": "Скрыть свойства",
"Find a property type": "Найти тип свойства",
"Properties": "Свойства"
"Properties": "Свойства",
"A property with this name already exists": "Свойство с таким именем уже существует",
"Add card": "Добавить карточку",
"Add filter": "Добавить фильтр",
"Add option": "Добавить вариант",
"Add sort": "Добавить сортировку",
"Add view": "Добавить представление",
"All data in this column will be lost.": "Все данные в этом столбце будут потеряны.",
"Allow multiple people": "Разрешить нескольких пользователей",
"Alphabetize": "По алфавиту",
"Are you sure you want to delete": "Вы уверены, что хотите удалить",
"Ascending": "По возрастанию",
"Base deleted": "База удалена",
"Base name": "Название базы",
"Base table": "Таблица базы",
"Card properties": "Свойства карточки",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Ячейки, длина которых превышает ограничение для текста, будут обрезаны, а лишнее содержимое будет безвозвратно потеряно.",
"Checkbox": "Флажок",
"Checked by default": "Отмечено по умолчанию",
"Choose a property": "Выберите свойство",
"Clear selection": "Очистить выбор",
"Column options": "Параметры столбца",
"Comma, period": "Запятая, точка",
"Complete": "Завершено",
"Contains": "Содержит",
"Copy link to view": "Скопировать ссылку на представление",
"Create a status property": "Создать свойство статуса",
"Created at": "Создано",
"Currency": "Валюта",
"Decimal places": "Десятичные знаки",
"Default value": "Значение по умолчанию",
"Delete property": "Удалить свойство",
"Delete view": "Удалить представление",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "По убыванию",
"Discard": "Отменить",
"Doesn't contain": "Не содержит",
"Done": "Готово",
"Edit formula": "Редактировать формулу",
"Edit property": "Редактировать свойство",
"Export CSV": "Экспорт CSV",
"Failed to load base": "Не удалось загрузить базу",
"False": "Ложь",
"File": "Файл",
"Filter and sort changes are visible only to you": "Изменения фильтра и сортировки видны только вам",
"Filter by": "Фильтр по",
"Formula": "Формула",
"Get started with": "Начать работу с",
"Greater than": "Больше чем",
"Group by": "Группировать по",
"Group this board by a select or status property.": "Сгруппируйте эту доску по свойству выбора или статуса.",
"Hide all": "Скрыть все",
"Hide group": "Скрыть группу",
"In Progress": "В процессе",
"In progress": "В процессе",
"Include time": "Включить время",
"Insert a kanban board on this page": "Вставить канбан-доску на эту страницу",
"Insert an inline base on this page": "Вставить встроенную базу на эту страницу",
"Is": "Равно",
"Is after": "После",
"Is any of": "Совпадает с любым из",
"Is before": "До",
"Is empty": "Пусто",
"Is none of": "Не совпадает ни с одним из",
"Is not": "Не равно",
"Is not empty": "Не пусто",
"Is on or after": "В этот день или позже",
"Is on or before": "В этот день или раньше",
"Is within": "Находится в пределах",
"Keep editing": "Продолжить редактирование",
"Last edited at": "Последнее изменение",
"Last edited by": "Последний изменивший",
"Less than": "Меньше чем",
"Link copied to clipboard": "Ссылка скопирована в буфер обмена",
"Local": "Локальный",
"Long text": "Длинный текст",
"Moved card to {{column}}": "Карточка перемещена в {{column}}",
"Moved column to {{column}}": "Столбец перемещён в {{column}}",
"Multi-select": "Множественный выбор",
"New row": "Новая строка",
"Next month": "Следующий месяц",
"Next week": "Следующая неделя",
"Next year": "Следующий год",
"No filters applied": "Фильтры не применены",
"No match": "Нет совпадений",
"No options for this property type": "Для этого типа свойства нет параметров",
"No sorts applied": "Сортировка не применена",
"No value": "Нет значения",
"None": "Нет",
"Not started": "Не начато",
"Number": "Число",
"One month ago": "Месяц назад",
"One month from now": "Через месяц",
"One week ago": "Неделю назад",
"One week from now": "Через неделю",
"Open as page": "Открыть как страницу",
"Options": "Параметры",
"Past month": "Прошлый месяц",
"Past week": "Прошлая неделя",
"Past year": "Прошлый год",
"Percent": "Процент",
"Period, comma": "Точка, запятая",
"Person": "Пользователь",
"Please enter a valid email": "Пожалуйста, введите корректный адрес электронной почты",
"Progress": "Прогресс",
"Property options": "Параметры свойства",
"Relative": "Относительный",
"Rename property": "Переименовать свойство",
"Reset": "Сбросить",
"Save for everyone": "Сохранить для всех",
"Select": "Выбор",
"Select a property": "Выберите свойство",
"Select all loaded rows": "Выбрать все загруженные строки",
"Show all": "Показать все",
"Sort": "Сортировка",
"Sort by": "Сортировать по",
"Space, comma": "Пробел, запятая",
"Space, period": "Пробел, точка",
"This board has no grouping property yet.": "У этой доски пока нет свойства для группировки.",
"This month": "Этот месяц",
"This year": "Этот год",
"Thousands and decimal separators": "Разделители тысяч и десятичных знаков",
"Time format": "Формат времени",
"To Do": "К выполнению",
"Tomorrow": "Завтра",
"True": "Истина",
"Unsaved changes": "Несохранённые изменения",
"Untitled base": "База без названия",
"URL": "URL",
"Value": "Значение",
"View updated for everyone": "Представление обновлено для всех",
"You have unsaved changes. Do you want to discard them?": "У вас есть несохранённые изменения. Хотите их отменить?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -990,6 +990,8 @@
"Are you sure you want to delete this template?": "Ви впевнені, що хочете видалити цей шаблон?",
"Template scope updated": "Область дії шаблону оновлено",
"Choose which space this template belongs to": "Виберіть, до якого простору належить цей шаблон",
"Only workspace admins can create global templates.": "Лише адміністратори робочого простору можуть створювати глобальні шаблони.",
"You need edit access to this space to create templates.": "Щоб створювати шаблони, вам потрібен доступ до редагування цього простору.",
"Scope": "Область дії",
"Select scope": "Вибрати область дії",
"Title": "Назва",
@@ -1151,5 +1153,141 @@
"Create property": "Створити властивість",
"Hide properties": "Приховати властивості",
"Find a property type": "Знайти тип властивості",
"Properties": "Властивості"
"Properties": "Властивості",
"A property with this name already exists": "Властивість із такою назвою вже існує",
"Add card": "Додати картку",
"Add filter": "Додати фільтр",
"Add option": "Додати опцію",
"Add sort": "Додати сортування",
"Add view": "Додати подання",
"All data in this column will be lost.": "Усі дані в цьому стовпці буде втрачено.",
"Allow multiple people": "Дозволити кількох людей",
"Alphabetize": "За алфавітом",
"Are you sure you want to delete": "Ви впевнені, що хочете видалити",
"Ascending": "За зростанням",
"Base deleted": "Базу видалено",
"Base name": "Назва бази",
"Base table": "Таблиця бази",
"Card properties": "Властивості картки",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Клітинки, довші за обмеження для тексту, буде обрізано, а зайвий вміст буде безповоротно втрачено.",
"Checkbox": "Прапорець",
"Checked by default": "Позначено за замовчуванням",
"Choose a property": "Оберіть властивість",
"Clear selection": "Очистити вибір",
"Column options": "Параметри стовпця",
"Comma, period": "Кома, крапка",
"Complete": "Завершено",
"Contains": "Містить",
"Copy link to view": "Скопіювати посилання на подання",
"Create a status property": "Створити властивість статусу",
"Created at": "Створено",
"Currency": "Валюта",
"Decimal places": "Десяткові знаки",
"Default value": "Значення за замовчуванням",
"Delete property": "Видалити властивість",
"Delete view": "Видалити подання",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "За спаданням",
"Discard": "Скасувати",
"Doesn't contain": "Не містить",
"Done": "Готово",
"Edit formula": "Редагувати формулу",
"Edit property": "Редагувати властивість",
"Export CSV": "Експорт CSV",
"Failed to load base": "Не вдалося завантажити базу",
"False": "Хибність",
"File": "Файл",
"Filter and sort changes are visible only to you": "Зміни фільтрів і сортування видимі лише вам",
"Filter by": "Фільтрувати за",
"Formula": "Формула",
"Get started with": "Почніть із",
"Greater than": "Більше ніж",
"Group by": "Групувати за",
"Group this board by a select or status property.": "Згрупуйте цю дошку за властивістю вибору або статусу.",
"Hide all": "Приховати все",
"Hide group": "Приховати групу",
"In Progress": "У процесі",
"In progress": "У процесі",
"Include time": "Включити час",
"Insert a kanban board on this page": "Вставити канбан-дошку на цю сторінку",
"Insert an inline base on this page": "Вставити вбудовану базу на цю сторінку",
"Is": "Є",
"Is after": "Після",
"Is any of": "Є одним із",
"Is before": "До",
"Is empty": "Порожнє",
"Is none of": "Не є жодним із",
"Is not": "Не є",
"Is not empty": "Не порожнє",
"Is on or after": "У цей день або пізніше",
"Is on or before": "У цей день або раніше",
"Is within": "У межах",
"Keep editing": "Продовжити редагування",
"Last edited at": "Востаннє відредаговано",
"Last edited by": "Востаннє відредагував",
"Less than": "Менше ніж",
"Link copied to clipboard": "Посилання скопійовано в буфер обміну",
"Local": "Локальний",
"Long text": "Довгий текст",
"Moved card to {{column}}": "Картку переміщено до {{column}}",
"Moved column to {{column}}": "Стовпець переміщено до {{column}}",
"Multi-select": "Мультивибір",
"New row": "Новий рядок",
"Next month": "Наступного місяця",
"Next week": "Наступного тижня",
"Next year": "Наступного року",
"No filters applied": "Фільтри не застосовано",
"No match": "Немає збігів",
"No options for this property type": "Немає опцій для цього типу властивості",
"No sorts applied": "Сортування не застосовано",
"No value": "Немає значення",
"None": "Немає",
"Not started": "Не розпочато",
"Number": "Число",
"One month ago": "Місяць тому",
"One month from now": "Через місяць",
"One week ago": "Тиждень тому",
"One week from now": "Через тиждень",
"Open as page": "Відкрити як сторінку",
"Options": "Опції",
"Past month": "За минулий місяць",
"Past week": "За минулий тиждень",
"Past year": "За минулий рік",
"Percent": "Відсоток",
"Period, comma": "Крапка, кома",
"Person": "Особа",
"Please enter a valid email": "Будь ласка, введіть коректну електронну адресу",
"Progress": "Прогрес",
"Property options": "Параметри властивості",
"Relative": "Відносний",
"Rename property": "Перейменувати властивість",
"Reset": "Скинути",
"Save for everyone": "Зберегти для всіх",
"Select": "Вибір",
"Select a property": "Виберіть властивість",
"Select all loaded rows": "Вибрати всі завантажені рядки",
"Show all": "Показати все",
"Sort": "Сортування",
"Sort by": "Сортувати за",
"Space, comma": "Пробіл, кома",
"Space, period": "Пробіл, крапка",
"This board has no grouping property yet.": "Ця дошка ще не має властивості групування.",
"This month": "Цього місяця",
"This year": "Цього року",
"Thousands and decimal separators": "Роздільники тисяч і десяткових знаків",
"Time format": "Формат часу",
"To Do": "До виконання",
"Tomorrow": "Завтра",
"True": "Істина",
"Unsaved changes": "Незбережені зміни",
"Untitled base": "База без назви",
"URL": "URL",
"Value": "Значення",
"View updated for everyone": "Подання оновлено для всіх",
"You have unsaved changes. Do you want to discard them?": "У вас є незбережені зміни. Хочете їх скасувати?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -990,6 +990,8 @@
"Are you sure you want to delete this template?": "你确定要删除此模板吗?",
"Template scope updated": "模板范围已更新",
"Choose which space this template belongs to": "选择此模板所属的空间",
"Only workspace admins can create global templates.": "只有工作区管理员才能创建全局模板。",
"You need edit access to this space to create templates.": "您需要拥有此空间的编辑权限才能创建模板。",
"Scope": "范围",
"Select scope": "选择范围",
"Title": "标题",
@@ -1151,5 +1153,141 @@
"Create property": "创建属性",
"Hide properties": "隐藏属性",
"Find a property type": "查找属性类型",
"Properties": "属性"
"Properties": "属性",
"A property with this name already exists": "已存在同名属性",
"Add card": "添加卡片",
"Add filter": "添加筛选",
"Add option": "添加选项",
"Add sort": "添加排序",
"Add view": "添加视图",
"All data in this column will be lost.": "此列中的所有数据都将丢失。",
"Allow multiple people": "允许多人",
"Alphabetize": "按字母顺序",
"Are you sure you want to delete": "您确定要删除",
"Ascending": "升序",
"Base deleted": "资料库已删除",
"Base name": "资料库名称",
"Base table": "资料库表",
"Card properties": "卡片属性",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "超出文本长度限制的单元格内容将被截断,额外内容将永久丢失。",
"Checkbox": "复选框",
"Checked by default": "默认选中",
"Choose a property": "选择一个属性",
"Clear selection": "清除选择",
"Column options": "列选项",
"Comma, period": "逗号、句点",
"Complete": "完成",
"Contains": "包含",
"Copy link to view": "复制视图链接",
"Create a status property": "创建状态属性",
"Created at": "创建于",
"Currency": "货币",
"Decimal places": "小数位数",
"Default value": "默认值",
"Delete property": "删除属性",
"Delete view": "删除视图",
"Delete {{count}} rows?_one": "删除 1 行?",
"Delete {{count}} rows?_other": "删除 {{count}} 行?",
"Descending": "降序",
"Discard": "放弃",
"Doesn't contain": "不包含",
"Done": "完成",
"Edit formula": "编辑公式",
"Edit property": "编辑属性",
"Export CSV": "导出 CSV",
"Failed to load base": "加载资料库失败",
"False": "假",
"File": "文件",
"Filter and sort changes are visible only to you": "筛选和排序更改仅对您可见",
"Filter by": "筛选条件",
"Formula": "公式",
"Get started with": "开始使用",
"Greater than": "大于",
"Group by": "分组依据",
"Group this board by a select or status property.": "按选择或状态属性对此看板进行分组。",
"Hide all": "全部隐藏",
"Hide group": "隐藏分组",
"In Progress": "进行中",
"In progress": "进行中",
"Include time": "包含时间",
"Insert a kanban board on this page": "在此页面插入一个看板",
"Insert an inline base on this page": "在此页面插入一个内联资料库",
"Is": "是",
"Is after": "在此之后",
"Is any of": "是其中之一",
"Is before": "在此之前",
"Is empty": "为空",
"Is none of": "都不是",
"Is not": "不是",
"Is not empty": "不为空",
"Is on or after": "在此日期或之后",
"Is on or before": "在此日期或之前",
"Is within": "在范围内",
"Keep editing": "继续编辑",
"Last edited at": "最后编辑于",
"Last edited by": "最后编辑者",
"Less than": "小于",
"Link copied to clipboard": "链接已复制到剪贴板",
"Local": "本地",
"Long text": "长文本",
"Moved card to {{column}}": "已将卡片移动到 {{column}}",
"Moved column to {{column}}": "已将列移动到 {{column}}",
"Multi-select": "多选",
"New row": "新行",
"Next month": "下个月",
"Next week": "下周",
"Next year": "明年",
"No filters applied": "未应用筛选",
"No match": "无匹配项",
"No options for this property type": "此属性类型没有可用选项",
"No sorts applied": "未应用排序",
"No value": "无值",
"None": "无",
"Not started": "未开始",
"Number": "数字",
"One month ago": "一个月前",
"One month from now": "从现在起一个月后",
"One week ago": "一周前",
"One week from now": "从现在起一周后",
"Open as page": "作为页面打开",
"Options": "选项",
"Past month": "过去一个月",
"Past week": "过去一周",
"Past year": "过去一年",
"Percent": "百分比",
"Period, comma": "句点、逗号",
"Person": "人员",
"Please enter a valid email": "请输入有效的电子邮件地址",
"Progress": "进度",
"Property options": "属性选项",
"Relative": "相对",
"Rename property": "重命名属性",
"Reset": "重置",
"Save for everyone": "保存给所有人",
"Select": "选择",
"Select a property": "选择一个属性",
"Select all loaded rows": "选择所有已加载的行",
"Show all": "显示全部",
"Sort": "排序",
"Sort by": "排序依据",
"Space, comma": "空格、逗号",
"Space, period": "空格、句点",
"This board has no grouping property yet.": "此看板尚无分组属性。",
"This month": "本月",
"This year": "今年",
"Thousands and decimal separators": "千位分隔符和小数分隔符",
"Time format": "时间格式",
"To Do": "待办",
"Tomorrow": "明天",
"True": "真",
"Unsaved changes": "未保存的更改",
"Untitled base": "未命名资料库",
"URL": "网址",
"Value": "值",
"View updated for everyone": "已为所有人更新视图",
"You have unsaved changes. Do you want to discard them?": "您有未保存的更改。要放弃这些更改吗?",
"{{count}} rows deleted_one": "已删除 1 行",
"{{count}} rows deleted_other": "已删除 {{count}} 行",
"{{count}} selected_one": "已选择 1 项",
"{{count}} selected_other": "已选择 {{count}} 项"
}
+6
View File
@@ -38,6 +38,9 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import Integrations from "@/features/integration/pages/integrations.tsx";
import Connections from "@/features/integration/pages/connections.tsx";
import SlackLinkPage from "@/features/integration/pages/slack-link.tsx";
import BasePage from "@/ee/base/pages/base-page.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
@@ -87,6 +90,7 @@ export default function App() {
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route path={"/integrations/slack/link"} element={<SlackLinkPage />} />
<Route element={<Layout />}>
<Route path={"/home"} element={<Home />} />
@@ -116,6 +120,7 @@ export default function App() {
element={<AccountPreferences />}
/>
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route path={"account/connections"} element={<Connections />} />
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
@@ -128,6 +133,7 @@ export default function App() {
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"verifications"} element={<VerifiedPages />} />
<Route path={"integrations"} element={<Integrations />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
@@ -0,0 +1,22 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GithubIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M256 6.3C114.6 6.3 0 120.9 0 262.3c0 113.3 73.3 209 175 242.9 12.8 2.2 17.6-5.4 17.6-12.2 0-6.1-.3-26.2-.3-47.7-64.3 11.8-81-15.7-86.1-30.1-2.9-7.4-15.4-30.1-26.2-36.2-9-4.8-21.8-16.6-.3-17 20.2-.3 34.6 18.6 39.4 26.2 23 38.7 59.8 27.8 74.6 21.1 2.2-16.6 9-27.8 16.3-34.2-57-6.4-116.5-28.5-116.5-126.4 0-27.8 9.9-50.9 26.2-68.8-2.6-6.4-11.5-32.6 2.6-67.8 0 0 21.4-6.7 70.4 26.2 20.5-5.8 42.2-8.6 64-8.6s43.5 2.9 64 8.6c49-33.3 70.4-26.2 70.4-26.2 14.1 35.2 5.1 61.4 2.6 67.8 16.3 17.9 26.2 40.6 26.2 68.8 0 98.2-59.8 120-116.8 126.4 9.3 8 17.3 23.4 17.3 47.4 0 34.2-.3 61.8-.3 70.4 0 6.7 4.8 14.7 17.6 12.2C438.7 471.3 512 375.3 512 262.3c0-141.4-114.6-256-256-256"
/>
</svg>
);
}
@@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GitlabIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="93.97 97.52 192.05 184.99"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#e24329"
d="m282.83 170.73-.27-.69-26.14-68.22a6.8 6.8 0 0 0-2.69-3.24 7 7 0 0 0-8 .43 7 7 0 0 0-2.32 3.52l-17.65 54h-71.47l-17.65-54a6.86 6.86 0 0 0-2.32-3.53 7 7 0 0 0-8-.43 6.87 6.87 0 0 0-2.69 3.24L97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91 40.06-30 .1-.08a48.56 48.56 0 0 0 16.08-56.04"
/>
<path
fill="#fc6d26"
d="m282.83 170.73-.27-.69a88.3 88.3 0 0 0-35.15 15.8L190 229.25c19.55 14.79 36.57 27.64 36.57 27.64l40.06-30 .1-.08a48.56 48.56 0 0 0 16.1-56.08"
/>
<path
fill="#fca326"
d="m153.43 256.89 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91S209.55 244 190 229.25c-19.55 14.75-36.57 27.64-36.57 27.64"
/>
<path
fill="#fc6d26"
d="M132.58 185.84A88.2 88.2 0 0 0 97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82L190 229.21Z"
/>
</svg>
);
}
@@ -0,0 +1,28 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GoogleDocsIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 47 65"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#4285F4"
d="M29.375 0H4.406C1.983 0 0 1.994 0 4.432v56.136C0 63.006 1.983 65 4.406 65h38.188C45.017 65 47 63.006 47 60.568v-42.84L36.719 10.34z"
/>
<path
fill="#A1C2FA"
d="M29.375 0v13.295c0 2.449 1.972 4.432 4.406 4.432H47z"
/>
<path
fill="#F1F1F1"
d="M11.75 47.273h23.5v-2.955h-23.5zm0 5.909h17.625v-2.955H11.75zm0-20.682v2.955h23.5V32.5zm0 8.864h23.5v-2.955h-23.5z"
/>
</svg>
);
}
+8 -2
View File
@@ -1,10 +1,16 @@
export { AirtableIcon } from "./airtable-icon.tsx";
export { FigmaIcon } from "./figma-icon.tsx";
export { GithubIcon } from "./github-icon.tsx";
export { GitlabIcon } from "./gitlab-icon.tsx";
export { GoogleDocsIcon } from "./google-docs-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { JiraIcon } from "./jira-icon.tsx";
export { LinearIcon } from "./linear-icon.tsx";
export { TypeformIcon } from "./typeform-icon.tsx";
export { VimeoIcon } from "./vimeo-icon.tsx";
export { MiroIcon } from "./miro-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { SlackIcon } from "./slack-icon.tsx";
export { FramerIcon } from "./framer-icon.tsx";
export { LoomIcon } from "./loom-icon.tsx";
export { YoutubeIcon } from "./youtube-icon.tsx";
@@ -0,0 +1,38 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function JiraIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<defs>
<linearGradient id="jira-a" x1="380.896" x2="279.145" y1="390.609" y2="282.574" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
<linearGradient id="jira-b" x1="265.965" x2="148.117" y1="270.661" y2="152.607" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
</defs>
<path
fill="#2684ff"
d="M490.6 7.4H243.9c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V28.1c.1-11.7-9.2-20.7-21.3-20.7"
/>
<path
fill="url(#jira-a)"
d="M368.7 126.5H121.9c0 59.6 49.9 108 111.2 108h45.6v42.9c0 59.6 49.9 108 111.2 108V147.3c.2-11.1-9.1-20.8-21.2-20.8"
/>
<path
fill="url(#jira-b)"
d="M246.7 246.3H0c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V267.1c.1-11.8-9.9-20.8-21.3-20.8"
/>
</svg>
);
}
@@ -0,0 +1,21 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function LinearIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 100 100"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"
/>
</svg>
);
}
+11 -2
View File
@@ -8,11 +8,20 @@ export function MiroIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
viewBox="3 2 395 395"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" />
fill="#FFDD33"
d="M3 100.754C3 46.2604 47.2435 2 101.754 2H299.246C353.756 2 398 46.2435 398 100.754V298.246C398 352.756 353.756 397 299.246 397H101.754C47.2435 397 3 352.756 3 298.246V100.754Z"
/>
<path
fill="#1C1C1E"
fillRule="evenodd"
clipRule="evenodd"
d="M265.573 77.3491H229.74L259.629 129.85L193.906 77.3491H158.072L190.934 141.468L122.238 77.3491H86.4041L122.238 159.031L86.4041 322.377H122.238L190.934 147.396L158.072 322.377H193.906L259.629 135.693L229.74 322.377H265.573L331.297 118.232L265.573 77.4335V77.3491Z"
/>
</svg>
);
}
@@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function SlackIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M107.9 323.6c0 29.7-24 53.8-53.8 53.8S.3 353.4.3 323.6c0-29.7 24-53.8 53.8-53.8h53.8zm26.9 0c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8V458c0 29.7-24 53.8-53.8 53.8s-53.8-24-53.8-53.8z"
fill="#e01e5a"
/>
<path
d="M188.6 107.7c-29.7 0-53.8-24-53.8-53.8S158.8.1 188.6.1s53.8 24 53.8 53.8v53.8zm0 27.3c29.7 0 53.8 24 53.8 53.8s-24 53.8-53.8 53.8H53.8C24 242.6 0 218.5 0 188.8S24 135 53.8 135z"
fill="#36c5f0"
/>
<path
d="M404.1 188.8c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8-24 53.8-53.8 53.8h-53.8zm-26.9 0c0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8V54c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8z"
fill="#2eb67d"
/>
<path
d="M323.4 404.3c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8v-53.8zm0-26.9c-29.7 0-53.8-24-53.8-53.8s24-53.8 53.8-53.8h134.8c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8z"
fill="#ecb22e"
/>
</svg>
);
}
@@ -13,6 +13,7 @@ import {
IconKey,
IconWorld,
IconSparkles,
IconPlug,
IconHistory,
IconShieldCheck,
} from "@tabler/icons-react";
@@ -74,6 +75,11 @@ const groupedData: DataGroup[] = [
path: "/settings/account/api-keys",
feature: Feature.API_KEYS,
},
{
label: "Connections",
icon: IconPlug,
path: "/settings/account/connections",
},
],
},
{
@@ -125,6 +131,12 @@ const groupedData: DataGroup[] = [
role: "owner",
env: "selfhosted",
},
{
label: "Integrations",
icon: IconPlug,
path: "/settings/integrations",
role: "admin",
},
],
},
{
@@ -0,0 +1,45 @@
import { describe, expect, it, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { HelmetProvider } from "react-helmet-async";
import { DocumentTitle } from "./document-title.tsx";
const renderTitle = (ui: React.ReactNode) =>
render(<HelmetProvider>{ui}</HelmetProvider>);
describe("DocumentTitle", () => {
beforeEach(() => {
document.head.innerHTML = "<title>Docmost</title>";
});
it("appends the app name", () => {
renderTitle(<DocumentTitle title="Home" />);
expect(document.title).toBe("Home - Docmost");
});
it("omits the app name when asked", () => {
renderTitle(<DocumentTitle title="My page" withAppName={false} />);
expect(document.title).toBe("My page");
});
it("falls back to the app name without a title", () => {
renderTitle(<DocumentTitle />);
expect(document.title).toBe("Docmost");
});
it("never renders an empty title", () => {
renderTitle(<DocumentTitle title="Spaces" />);
const titles = Array.from(document.querySelectorAll("head > title"));
expect(titles.every((node) => node.textContent !== "")).toBe(true);
});
it("renders extra head children", () => {
renderTitle(
<DocumentTitle title="Shared">
<meta name="robots" content="noindex" />
</DocumentTitle>,
);
expect(
document.querySelector('head > meta[name="robots"]')?.getAttribute("content"),
).toBe("noindex");
});
});
@@ -0,0 +1,29 @@
import React from "react";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
type DocumentTitleProps = {
title?: string;
withAppName?: boolean;
children?: React.ReactNode;
};
export function DocumentTitle({
title,
withAppName = true,
children,
}: DocumentTitleProps) {
const appName = getAppName();
let documentTitle = appName;
if (title) {
documentTitle = withAppName ? `${title} - ${appName}` : title;
}
return (
<Helmet>
<title>{documentTitle}</title>
{children}
</Helmet>
);
}
+2 -4
View File
@@ -1,17 +1,15 @@
import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css";
import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export function Error404() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("404 page not found")} - Docmost</title>
</Helmet>
<DocumentTitle title={t("404 page not found")} />
<Container className={classes.root}>
<Title className={classes.title}>{t("404 page not found")}</Title>
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
@@ -255,6 +255,7 @@ export default function ChatInput({
},
content: "",
editable: true,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: autofocus ? "end" : false,
+2 -5
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
@@ -15,6 +13,7 @@ import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AiSettings() {
const { t } = useTranslation();
@@ -40,9 +39,7 @@ export default function AiSettings() {
return (
<>
<Helmet>
<title>AI settings - {getAppName()}</title>
</Helmet>
<DocumentTitle title="AI settings" />
<SettingsTitle title={t("AI settings")} />
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
@@ -1,10 +1,9 @@
import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName, getAppUrl } from "@/lib/config";
import { getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -17,6 +16,7 @@ import { IApiKey } from "@/ee/api-key";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function UserApiKeys() {
const { t } = useTranslation();
@@ -49,11 +49,7 @@ export default function UserApiKeys() {
return (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("API keys")} />
<SettingsTitle title={t("API keys")} />
@@ -1,9 +1,7 @@
import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -15,6 +13,7 @@ import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
@@ -47,11 +46,7 @@ export default function WorkspaceApiKeys() {
return (
<>
<Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("API management")} />
<SettingsTitle title={t("API management")} />
@@ -10,11 +10,9 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import {
@@ -26,6 +24,7 @@ import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
type RetentionUnit = "days" | "months" | "years";
@@ -97,11 +96,7 @@ export default function AuditLogs() {
return (
<>
<Helmet>
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Audit log")} />
<SettingsTitle title={t("Audit log")} />
@@ -30,7 +30,7 @@ type BaseKanbanProps = {
export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
const { t } = useTranslation();
const { groupByPropertyId, columns, hasValidGroupBy } = useKanbanColumns(base, view);
const { groupByPropertyId, groupByProperty, columns, hasValidGroupBy } = useKanbanColumns(base, view);
const updateView = useUpdateViewMutation();
const moveCard = useKanbanMoveCardMutation();
const { openRow } = useRowDetailModal(pageId);
@@ -201,6 +201,7 @@ export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter
column={column}
viewFilter={viewFilter}
groupByPropertyId={groupByPropertyId!}
groupByProperty={groupByProperty}
canEdit={editable}
onOpenRow={handleOpenRow}
onHide={hideColumn}
@@ -1,24 +1,27 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Menu, Text } from "@mantine/core";
import { IconDots, IconPlus, IconGripVertical } from "@tabler/icons-react";
import { ActionIcon, Text } from "@mantine/core";
import { IconPlus, IconGripVertical } from "@tabler/icons-react";
import clsx from "clsx";
import { KanbanColumn } from "@/ee/base/types/base.types";
import { IBaseProperty, KanbanColumn } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import { KanbanColumnTitle } from "@/ee/base/components/kanban/kanban-column-title";
import { KanbanColumnMenu } from "@/ee/base/components/kanban/kanban-column-menu";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnHeaderProps = {
column: KanbanColumn;
pageId: string;
property: IBaseProperty | undefined;
count?: string;
canEdit: boolean;
onHide: () => void;
onAddCard: () => void;
};
export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
export function KanbanColumnHeader({ column, pageId, property, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
const { t } = useTranslation();
const dotColor = column.color
? choiceColor(column.color).color as string
@@ -49,22 +52,13 @@ export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onA
background: dotColor,
}}
/>
<Text fw={600} size="sm" flex={1} truncate>
{column.isNoValue ? t("No value") : column.name}
</Text>
<KanbanColumnTitle column={column} property={property} pageId={pageId} canEdit={canEdit} />
{count !== undefined && <Text className={classes.count}>{count}</Text>}
{canEdit && (
<>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Column options")}>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onHide}>{t("Hide group")}</Menu.Item>
</Menu.Dropdown>
</Menu>
{property && (
<KanbanColumnMenu property={property} pageId={pageId} onHide={onHide} />
)}
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add card")} onClick={onAddCard}>
<IconPlus size={14} />
</ActionIcon>
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Popover, Stack } from "@mantine/core";
import { IconDots, IconEyeOff, IconSettings } from "@tabler/icons-react";
import { useAtom } from "jotai";
import {
propertyMenuCloseRequestAtomFamily,
propertyMenuDirtyAtomFamily,
} from "@/ee/base/atoms/base-atoms";
import { IBaseProperty } from "@/ee/base/types/base.types";
import {
MenuItem,
PropertyMenuContent,
} from "@/ee/base/components/property/property-menu";
type KanbanColumnMenuProps = {
property: IBaseProperty;
pageId: string;
onHide: () => void;
};
export function KanbanColumnMenu({ property, pageId, onHide }: KanbanColumnMenuProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [view, setView] = useState<"menu" | "property">("menu");
const [dirty, setDirty] = useAtom(propertyMenuDirtyAtomFamily(pageId)) as unknown as [boolean, (val: boolean) => void];
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
const handleClose = useCallback(() => {
setOpened(false);
setView("menu");
}, []);
const wasOpenedRef = useRef(opened);
useEffect(() => {
if (wasOpenedRef.current && !opened) setDirty(false);
wasOpenedRef.current = opened;
}, [opened, setDirty]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (next) return;
if (dirty) {
setCloseRequest(closeRequest + 1);
} else {
handleClose();
}
},
[dirty, closeRequest, setCloseRequest, handleClose],
);
const toggle = useCallback(() => {
if (opened) {
handleOpenChange(false);
} else if (!dirty) {
setOpened(true);
}
}, [opened, dirty, handleOpenChange]);
return (
<Popover
opened={opened}
onChange={handleOpenChange}
onClose={handleClose}
position="bottom-end"
shadow="md"
width={260}
trapFocus
returnFocus
withinPortal
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
aria-label={t("Column options")}
onClick={toggle}
>
<IconDots size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{view === "menu" ? (
<Stack gap={0} p={4}>
<MenuItem
icon={<IconSettings size={14} />}
label={t("Edit property")}
onClick={() => setView("property")}
/>
<MenuItem
icon={<IconEyeOff size={14} />}
label={t("Hide group")}
onClick={() => {
handleClose();
onHide();
}}
/>
</Stack>
) : (
<PropertyMenuContent
property={property}
opened={opened}
onClose={handleClose}
onDirtyChange={setDirty}
pageId={pageId}
initialPanel={property.pendingType ? "main" : "options"}
/>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,113 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Group, Popover, Text, TextInput, UnstyledButton } from "@mantine/core";
import { IBaseProperty, KanbanColumn, SelectTypeOptions } from "@/ee/base/types/base.types";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnTitleProps = {
column: KanbanColumn;
property: IBaseProperty | undefined;
pageId: string;
canEdit: boolean;
};
export function KanbanColumnTitle({ column, property, pageId, canEdit }: KanbanColumnTitleProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [draft, setDraft] = useState("");
const updateProperty = useUpdatePropertyMutation();
const commit = useCallback(() => {
setOpened(false);
const name = draft.trim();
const options = property?.typeOptions as SelectTypeOptions | undefined;
if (!property || !options || !name || name === column.name) return;
if (!options.choices.some((c) => c.id === column.key)) return;
updateProperty.mutate({
propertyId: property.id,
pageId,
typeOptions: {
...options,
choices: options.choices.map((c) =>
c.id === column.key ? { ...c, name } : c,
),
},
});
}, [draft, property, column.name, column.key, pageId, updateProperty]);
const toggle = useCallback(() => {
if (opened) {
commit();
} else {
setDraft(column.name);
setOpened(true);
}
}, [opened, commit, column.name]);
const cancel = useCallback(() => setOpened(false), []);
if (!canEdit || column.isNoValue || !property) {
return (
<Text fw={600} size="sm" flex={1} truncate>
{column.isNoValue ? t("No value") : column.name}
</Text>
);
}
return (
<Popover
opened={opened}
onChange={(next) => {
if (!next) commit();
}}
position="bottom-start"
shadow="md"
width={240}
withinPortal
trapFocus
returnFocus
closeOnClickOutside
closeOnEscape={false}
>
<Popover.Target>
<UnstyledButton className={classes.columnTitleButton} onClick={toggle}>
<Text fw={600} size="sm" truncate flex={1} ta="left">
{column.name}
</Text>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown
p="xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
cancel();
}
}}
>
<Group gap="xs" wrap="nowrap">
<TextInput
size="xs"
flex={1}
value={draft}
data-autofocus
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
}
}}
/>
<Button size="xs" onClick={commit}>
{t("Done")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { type IBase, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
import { type IBase, type IBaseProperty, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
import { formatKanbanCount } from "@/ee/base/services/format-kanban-count";
import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
@@ -19,6 +19,7 @@ type KanbanColumnProps = {
column: KanbanColumnType;
viewFilter: FilterGroup | undefined;
groupByPropertyId: string;
groupByProperty: IBaseProperty | undefined;
canEdit: boolean;
onOpenRow: (rowId: string) => void;
onHide: (columnKey: string) => void;
@@ -33,6 +34,7 @@ export function KanbanColumn({
column,
viewFilter,
groupByPropertyId,
groupByProperty,
canEdit,
onOpenRow,
onHide,
@@ -139,6 +141,7 @@ export function KanbanColumn({
<KanbanColumnHeader
column={column}
pageId={pageId}
property={groupByProperty}
count={count}
canEdit={canEdit}
onHide={() => onHide(column.key)}
@@ -53,6 +53,7 @@ type PropertyMenuContentProps = {
onDirtyChange?: (dirty: boolean) => void;
onEditFormula?: () => void;
pageId: string;
initialPanel?: "main" | "options";
};
type MenuPanel =
@@ -96,9 +97,10 @@ export function PropertyMenuContent({
onDirtyChange,
onEditFormula,
pageId,
initialPanel,
}: PropertyMenuContentProps) {
const { t } = useTranslation();
const [panel, setPanel] = useState<MenuPanel>("main");
const [panel, setPanel] = useState<MenuPanel>(initialPanel ?? "main");
const [renameValue, setRenameValue] = useState(property.name);
const renameInputRef = useRef<HTMLInputElement>(null);
const [optionsDirty, setOptionsDirty] = useState(false);
@@ -117,12 +119,12 @@ export function PropertyMenuContent({
useEffect(() => {
if (opened) {
setPanel("main");
setPanel(initialPanel ?? "main");
setRenameValue(property.name);
setOptionsDirty(false);
setPendingTargetType(null);
}
}, [opened, property.name]);
}, [opened, property.name, initialPanel]);
useEffect(() => {
if (panel === "rename") {
@@ -463,7 +465,7 @@ export function PropertyMenuContent({
PropertyMenuContent.displayName = "PropertyMenuContent";
function MenuItem({
export function MenuItem({
icon,
label,
rightIcon,
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { IBase, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
import { IBase, IBaseProperty, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
export type KanbanGroup = KanbanColumn & { hidden: boolean };
@@ -8,6 +8,7 @@ export function useKanbanColumns(
view: IBaseView | undefined,
): {
groupByPropertyId: string | undefined;
groupByProperty: IBaseProperty | undefined;
columns: KanbanColumn[];
allGroups: KanbanGroup[];
hasValidGroupBy: boolean;
@@ -18,7 +19,7 @@ export function useKanbanColumns(
const groupable = prop && (prop.type === "select" || prop.type === "status");
if (!groupable || !prop || !view) {
return { groupByPropertyId, columns: [], allGroups: [], hasValidGroupBy: false };
return { groupByPropertyId, groupByProperty: undefined, columns: [], allGroups: [], hasValidGroupBy: false };
}
const typeOptions = prop.typeOptions as SelectTypeOptions;
@@ -47,6 +48,6 @@ export function useKanbanColumns(
});
const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden);
return { groupByPropertyId, columns, allGroups, hasValidGroupBy: true };
return { groupByPropertyId, groupByProperty: prop, columns, allGroups, hasValidGroupBy: true };
}, [base, view]);
}
@@ -76,6 +76,21 @@
cursor: grabbing;
}
.columnTitleButton {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
padding: 2px 6px;
margin: -2px -6px;
border-radius: 6px;
cursor: pointer;
}
.columnTitleButton:hover {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
.cardList {
display: flex;
flex-direction: column;
+2 -5
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import BillingPlans from "@/ee/billing/components/billing-plans.tsx";
import BillingTrial from "@/ee/billing/components/billing-trial.tsx";
@@ -9,6 +7,7 @@ import React from "react";
import BillingDetails from "@/ee/billing/components/billing-details.tsx";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Billing() {
const { data: billing, isError: isBillingError } = useBillingQuery();
@@ -20,9 +19,7 @@ export default function Billing() {
return (
<>
<Helmet>
<title>Billing - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Billing" />
<SettingsTitle title="Billing" />
<BillingTrial />
+1
View File
@@ -22,4 +22,5 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
INTEGRATIONS: 'integrations',
} as const;
+2 -5
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
@@ -9,6 +7,7 @@ import InstallationDetails from "@/ee/licence/components/installation-details.ts
import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function License() {
const [entitlements] = useAtom(entitlementAtom);
@@ -21,9 +20,7 @@ export default function License() {
return (
<>
<Helmet>
<title>License - {getAppName()}</title>
</Helmet>
<DocumentTitle title="License" />
<SettingsTitle title="License" />
<ActivateLicenseForm />
@@ -1,17 +1,16 @@
import { useState, useMemo } from "react";
import { Group, MultiSelect, Select, Space, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSearch } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useVerificationListQuery } from "@/ee/page-verification/queries/page-verification-query";
import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types";
import VerificationListTable from "@/ee/page-verification/components/verification-list-table";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function VerifiedPages() {
const { t } = useTranslation();
@@ -68,11 +67,7 @@ export default function VerifiedPages() {
return (
<>
<Helmet>
<title>
{t("Verified pages")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Verified pages")} />
<SettingsTitle title={t("Verified pages")} />
+2 -7
View File
@@ -1,18 +1,13 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { CloudLoginForm } from "@/ee/components/cloud-login-form.tsx";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CloudLogin() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Login")} />
<CloudLoginForm />
</>
@@ -1,14 +1,11 @@
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React from "react";
import { getAppName } from "@/lib/config.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CreateWorkspace() {
return (
<>
<Helmet>
<title>Create Workspace - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Create Workspace" />
<SetupWorkspaceForm />
</>
);
@@ -1,5 +1,4 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import { isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import {
Alert,
@@ -37,6 +36,7 @@ import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const SCIM_TOKEN_LIMIT = 5;
@@ -64,9 +64,7 @@ export default function Security() {
return (
<>
<Helmet>
<title>Security - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Security" />
<SettingsTitle title={t("Security")} />
<EnforceMfa />
@@ -41,6 +41,7 @@ export default function ReadonlyTemplateEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={template.content}
/>
@@ -22,8 +22,6 @@ import { useTranslation } from "react-i18next";
import { useDisclosure, useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { Link, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useEditor, EditorContent } from "@tiptap/react";
import { templateExtensions } from "@/features/editor/extensions/extensions";
import {
@@ -44,6 +42,7 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import classes from "./template-editor.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateEditor() {
const { t } = useTranslation();
@@ -88,6 +87,7 @@ export default function TemplateEditor() {
const editor = useEditor({
extensions: templateExtensions,
content: "",
textDirection: "auto",
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
@@ -247,11 +247,7 @@ export default function TemplateEditor() {
return (
<>
<Helmet>
<title>
{t("Edit template")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Edit template")} />
{editorToolbarEnabled && editor && (
<FixedToolbar editor={editor} templateMode />
@@ -13,11 +13,9 @@ import {
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { IconPlus } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDisclosure } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import {
useGetTemplatesQuery,
useDeleteTemplateMutation,
@@ -31,6 +29,7 @@ import useUserRole from "@/hooks/use-user-role";
import CreateTemplateModal from "@/ee/template/components/create-template-modal";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateList() {
const { t } = useTranslation();
@@ -102,11 +101,7 @@ export default function TemplateList() {
return (
<>
<Helmet>
<title>
{t("Templates")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Templates")} />
<Container size="900" pt="xl">
<Group justify="space-between" mb="xl">
@@ -0,0 +1,59 @@
import { ThemeIcon } from "@mantine/core";
import {
IconFile,
IconFileTypeCsv,
IconFileTypeDocx,
IconFileTypePdf,
IconFileTypePpt,
IconFileTypeXls,
IconFileZip,
IconMovie,
IconMusic,
IconPhoto,
type Icon,
} from "@tabler/icons-react";
const EXT_ICONS: Record<string, { icon: Icon; color: string }> = {
".pdf": { icon: IconFileTypePdf, color: "red" },
".doc": { icon: IconFileTypeDocx, color: "blue" },
".docx": { icon: IconFileTypeDocx, color: "blue" },
".xls": { icon: IconFileTypeXls, color: "teal" },
".xlsx": { icon: IconFileTypeXls, color: "teal" },
".csv": { icon: IconFileTypeCsv, color: "teal" },
".ppt": { icon: IconFileTypePpt, color: "orange" },
".pptx": { icon: IconFileTypePpt, color: "orange" },
".zip": { icon: IconFileZip, color: "gray" },
".rar": { icon: IconFileZip, color: "gray" },
".7z": { icon: IconFileZip, color: "gray" },
".tar": { icon: IconFileZip, color: "gray" },
".gz": { icon: IconFileZip, color: "gray" },
};
const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [
{ prefix: "image/", icon: IconPhoto, color: "grape" },
{ prefix: "video/", icon: IconMovie, color: "violet" },
{ prefix: "audio/", icon: IconMusic, color: "pink" },
];
interface AttachmentFileIconProps {
fileExt?: string;
mimeType?: string;
}
export function AttachmentFileIcon({
fileExt,
mimeType,
}: AttachmentFileIconProps) {
const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined;
const byMime = mimeType
? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix))
: undefined;
const { icon: FileIcon, color } = byExt ??
byMime ?? { icon: IconFile, color: "gray" };
return (
<ThemeIcon variant="light" color={color} size={40} radius="md">
<FileIcon size={22} stroke={1.5} />
</ThemeIcon>
);
}
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Anchor,
Center,
Group,
Loader,
Modal,
ScrollArea,
Text,
Tooltip,
} from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { SearchInput } from "@/components/common/search-input.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx";
import { formatBytes } from "@/lib";
import { getFileUrl } from "@/lib/config.ts";
import { formattedDate } from "@/lib/time.ts";
interface PageAttachmentsModalProps {
pageId: string;
open: boolean;
onClose: () => void;
}
export default function PageAttachmentsModal({
pageId,
open,
onClose,
}: PageAttachmentsModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={open}
onClose={onClose}
title={t("Attachments")}
size={800}
closeButtonProps={{ "aria-label": t("Close") }}
>
<PageAttachmentsList pageId={pageId} />
</Modal>
);
}
function PageAttachmentsList({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const {
data,
isLoading,
isError,
isFetching,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageAttachmentsQuery(pageId, search);
const attachments = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
);
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetching) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetching]);
const handleSearch = useCallback((value: string) => setSearch(value), []);
return (
<>
<SearchInput
onSearch={handleSearch}
placeholder={t("Search attachments...")}
/>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : isError ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{t("Error loading attachments.")}
</Text>
</Center>
) : attachments.length === 0 ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{search
? t("No results found")
: t("No attachments on this page yet.")}
</Text>
</Center>
) : (
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
{attachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea.Autosize>
)}
</>
);
}
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
const { t } = useTranslation();
const fileUrl = getFileUrl(attachment.url);
return (
<Group wrap="nowrap" gap="md" py="sm" pr="xs">
<AttachmentFileIcon
fileExt={attachment.fileExt}
mimeType={attachment.mimeType}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Anchor
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
fw={500}
c="inherit"
truncate="end"
style={{ display: "block" }}
>
{attachment.fileName}
</Anchor>
<Text size="xs" c="dimmed" mt={2} truncate="end">
{formatBytes(Number(attachment.fileSize))}
{" · "}
{formattedDate(new Date(attachment.createdAt))}
</Text>
</div>
{attachment.creator && (
<Tooltip
label={t("Uploaded by {{name}}", { name: attachment.creator.name })}
withArrow
>
<CustomAvatar
avatarUrl={attachment.creator.avatarUrl}
name={attachment.creator.name}
size="sm"
/>
</Tooltip>
)}
<Tooltip label={t("Download attachment")} withArrow>
<ActionIcon
component="a"
href={fileUrl}
download={attachment.fileName}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
aria-label={t("Download {{name}}", { name: attachment.fileName })}
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
</Group>
);
}
@@ -0,0 +1,25 @@
import {
InfiniteData,
keepPreviousData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { IPagination } from "@/lib/types.ts";
export function usePageAttachmentsQuery(
pageId: string,
search?: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageAttachment>, unknown>> {
return useInfiniteQuery({
queryKey: ["page-attachments", pageId, search],
queryFn: ({ pageParam }) =>
getPageAttachments(pageId, { cursor: pageParam, query: search }),
enabled: !!pageId,
gcTime: 0,
placeholderData: keepPreviousData,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -3,7 +3,17 @@ import loadImage from "blueimp-load-image";
import {
AvatarIconType,
IAttachment,
IPageAttachment,
} from "@/features/attachments/types/attachment.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getPageAttachments(
pageId: string,
params?: QueryParams,
): Promise<IPagination<IPageAttachment>> {
const req = await api.post("/pages/attachments", { pageId, ...params });
return req.data;
}
async function compressAndResizeIcon(
file: File,
@@ -1,4 +1,5 @@
export {
getPageAttachments,
uploadIcon,
uploadUserAvatar,
uploadSpaceIcon,
@@ -15,6 +15,15 @@ export interface IAttachment {
deletedAt: string | null;
}
export interface IPageAttachment extends IAttachment {
url: string;
creator: {
id: string;
name: string;
avatarUrl: string | null;
} | null;
}
export enum AvatarIconType {
AVATAR = "avatar",
SPACE_ICON = "space-icon",
@@ -103,6 +103,7 @@ const CommentEditor = forwardRef(
},
content: defaultContent,
editable,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: (autofocus && "end") || false,
@@ -0,0 +1,46 @@
import {
HocuspocusProviderWebsocket,
WebSocketStatus,
} from "@hocuspocus/provider";
import { getCollaborationUrl } from "@/lib/config.ts";
const RELEASE_GRACE_MS = 5000;
let socket: HocuspocusProviderWebsocket | null = null;
let editorCount = 0;
let releaseTimer: ReturnType<typeof setTimeout> | null = null;
export function getCollabSocket(): HocuspocusProviderWebsocket {
if (!socket) {
socket = new HocuspocusProviderWebsocket({
url: getCollaborationUrl(),
autoConnect: false,
});
}
return socket;
}
export function acquireCollabSocket(): void {
editorCount++;
if (releaseTimer) {
clearTimeout(releaseTimer);
releaseTimer = null;
}
const collabSocket = getCollabSocket();
collabSocket.shouldConnect = true;
if (collabSocket.status === WebSocketStatus.Disconnected) {
collabSocket.connect();
}
}
export function releaseCollabSocket(): void {
editorCount--;
if (editorCount > 0) return;
if (releaseTimer) clearTimeout(releaseTimer);
releaseTimer = setTimeout(() => {
releaseTimer = null;
if (editorCount === 0) {
socket?.disconnect();
}
}, RELEASE_GRACE_MS);
}
@@ -2,6 +2,8 @@ import type { Editor, Range } from "@tiptap/core";
import { v7 as uuid7 } from "uuid";
import { notifications } from "@mantine/notifications";
import api from "@/lib/api-client";
import i18n from "@/i18n.ts";
import { getApiErrorMessage } from "@/lib/api-error";
function findBaseEmbedPlaceholderPos(
editor: Editor,
@@ -50,7 +52,7 @@ export async function insertBaseEmbedBlock(
return true;
})
.run();
} catch {
} catch (err) {
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
if (pos !== null) {
editor
@@ -62,6 +64,9 @@ export async function insertBaseEmbedBlock(
})
.run();
}
notifications.show({ message: "Failed to create base", color: "red" });
notifications.show({
message: getApiErrorMessage(err, i18n.t("Failed to create base")),
color: "red",
});
}
}
@@ -5,6 +5,10 @@ import { uploadPdfAction } from "../pdf/upload-pdf-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
import { matchIntegrationLink } from "@docmost/editor-ext";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
import { queryClient } from "@/main.tsx";
import { Integration } from "@/features/integration/types/integration.types";
import {
getAttachmentInfo,
uploadFile,
@@ -22,6 +26,16 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
// Only installed providers get card treatment; anything else pastes as an
// ordinary link. The cache is prefetched when the page editor mounts;
// a cold cache also means ordinary link.
function isIntegrationInstalled(provider: string): boolean {
const installed = queryClient.getQueryData<Integration[]>([
"installed-integrations",
]);
return Boolean(installed?.some((i) => i.type === provider));
}
export const handlePaste = (
editor: Editor,
event: ClipboardEvent,
@@ -30,6 +44,59 @@ export const handlePaste = (
) => {
const clipboardData = event.clipboardData.getData("text/plain");
const integrationMatch = matchIntegrationLink(clipboardData.trim());
if (
integrationMatch &&
editor.state.selection.empty &&
isIntegrationInstalled(integrationMatch.provider)
) {
event.preventDefault();
const pastedUrl = clipboardData.trim();
editor
.chain()
.focus()
.setIntegrationLink({
url: pastedUrl,
provider: integrationMatch.provider,
})
// Anchor the "Paste as" menu to the inserted node, in the SAME
// transaction: BubbleMenu ignores meta-only transactions (it only
// re-evaluates when the doc or selection changed). Locate the node via
// the range this transaction's own steps touched, never by url, so a
// duplicate of the same link elsewhere in the doc can't steal the menu.
.command(({ tr }) => {
let start: number | null = null;
let end: number | null = null;
tr.mapping.maps.forEach((map, index) => {
const rest = tr.mapping.slice(index + 1);
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
const mappedStart = rest.map(newStart, -1);
const mappedEnd = rest.map(newEnd, 1);
start = start === null ? mappedStart : Math.min(start, mappedStart);
end = end === null ? mappedEnd : Math.max(end, mappedEnd);
});
});
if (start === null || end === null) return true;
let pastedPos: number | null = null;
tr.doc.nodesBetween(
start,
Math.min(end, tr.doc.content.size),
(node, pos) => {
if (node.type.name === "integrationLink") {
pastedPos = pos;
}
},
);
if (pastedPos !== null) {
tr.setMeta(integrationPasteMenuKey, { pos: pastedPos });
}
return true;
})
.run();
return true;
}
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();
@@ -6,6 +6,7 @@
z-index: 99;
display: flex;
align-items: center;
min-height: 45px;
background: var(--mantine-color-body);
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
@@ -31,8 +31,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
if (!editor || !state) return null;
return (
<>
<div
@@ -49,22 +47,26 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
<div className={classes.divider} />
</>
)} */}
<BlockTypeGroup editor={editor} />
<div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} />
<div className={classes.divider} />
<ColorGroup editor={editor} />
<div className={classes.divider} />
<ListsGroup editor={editor} state={state} />
<div className={classes.divider} />
<AlignmentGroup editor={editor} />
<div className={classes.divider} />
<MediaGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<QuickInsertsGroup editor={editor} />
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<HistoryGroup editor={editor} state={state} />
{editor && state && (
<>
<BlockTypeGroup editor={editor} />
<div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} />
<div className={classes.divider} />
<ColorGroup editor={editor} />
<div className={classes.divider} />
<ListsGroup editor={editor} state={state} />
<div className={classes.divider} />
<AlignmentGroup editor={editor} />
<div className={classes.divider} />
<MediaGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<QuickInsertsGroup editor={editor} />
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<HistoryGroup editor={editor} state={state} />
</>
)}
</div>
</div>
<div className={classes.spacer} aria-hidden />
@@ -1,6 +1,6 @@
import { FC } from "react";
import type { Editor } from "@tiptap/react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { ActionIcon, Badge, Menu, Tooltip } from "@mantine/core";
import {
IconAppWindow,
IconCalendar,
@@ -12,6 +12,7 @@ import {
IconMathFunction,
IconRotate2,
IconSitemap,
IconSuperscript,
IconTable,
IconTag,
} from "@tabler/icons-react";
@@ -32,6 +33,9 @@ import {
} from "@/components/icons";
import { useTranslation } from "react-i18next";
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
interface Props {
editor: Editor;
@@ -40,6 +44,8 @@ interface Props {
export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
const { t, i18n } = useTranslation();
const hasBases = useHasFeature(Feature.BASES);
const upgradeLabel = useUpgradeLabel();
const setEmbed = (provider: string) =>
editor.chain().focus().setEmbed({ provider }).run();
@@ -106,20 +112,48 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
</Menu.Item>
)}
{!templateMode && (
<Menu.Item
leftSection={<IconTable size={16} />}
onClick={() => insertBaseEmbedBlock(editor)}
>
{t("Base (Inline)")}
</Menu.Item>
<Tooltip label={upgradeLabel} disabled={hasBases} position="right">
<Menu.Item
leftSection={<IconTable size={16} />}
aria-disabled={!hasBases}
closeMenuOnClick={hasBases}
style={{ opacity: hasBases ? undefined : 0.7 }}
rightSection={
!hasBases && (
<Badge size="xs" variant="light" color="gray">
{t("Upgrade")}
</Badge>
)
}
onClick={() => {
if (hasBases) insertBaseEmbedBlock(editor);
}}
>
{t("Base (Inline)")}
</Menu.Item>
</Tooltip>
)}
{!templateMode && (
<Menu.Item
leftSection={<IconLayoutKanban size={16} />}
onClick={() => insertBaseEmbedBlock(editor, { template: "kanban" })}
>
{t("Kanban")}
</Menu.Item>
<Tooltip label={upgradeLabel} disabled={hasBases} position="right">
<Menu.Item
leftSection={<IconLayoutKanban size={16} />}
aria-disabled={!hasBases}
closeMenuOnClick={hasBases}
style={{ opacity: hasBases ? undefined : 0.7 }}
rightSection={
!hasBases && (
<Badge size="xs" variant="light" color="gray">
{t("Upgrade")}
</Badge>
)
}
onClick={() => {
if (hasBases) insertBaseEmbedBlock(editor, { template: "kanban" });
}}
>
{t("Kanban")}
</Menu.Item>
</Tooltip>
)}
<Menu.Divider />
@@ -237,6 +271,12 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
>
{t("Math block")}
</Menu.Item>
<Menu.Item
leftSection={<IconSuperscript size={16} />}
onClick={() => editor.chain().focus().addFootnote().run()}
>
{t("Footnote")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
@@ -0,0 +1,49 @@
// Light-scheme text per hue, measured to pass 4.5:1 on the light-variant
// badge background; hexes are darkened .9 shades for hues whose scale
// never gets dark enough.
const BADGE_TEXT_LIGHT: Record<string, string> = {
dark: "var(--mantine-color-dark-9)",
gray: "var(--mantine-color-gray-9)",
red: "var(--mantine-color-red-9)",
pink: "var(--mantine-color-pink-9)",
grape: "var(--mantine-color-grape-9)",
violet: "var(--mantine-color-violet-9)",
indigo: "var(--mantine-color-indigo-9)",
blue: "var(--mantine-color-blue-9)",
cyan: "var(--mantine-color-cyan-9)",
teal: "var(--mantine-color-teal-9)",
green: "#277c38",
lime: "#4e7e0b",
yellow: "#ad5900",
orange: "#c3410e",
};
export function badgeTextColor(color?: string): string | undefined {
if (!color) return undefined;
const light = BADGE_TEXT_LIGHT[color];
if (!light) return undefined;
// Dark scheme keeps Mantine's own light-variant text.
return `light-dark(${light}, var(--mantine-color-${color}-light-color))`;
}
export function toBadgeColor(raw?: string): string {
if (!raw) return "gray";
const hex = raw.toLowerCase().replace("#", "");
if (/^[0-9a-f]{6}$/.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2 / 255;
if (max - min < 30) return l > 0.6 ? "gray" : "dark";
if (r > g && r > b) return g > 160 ? "orange" : "red";
if (g > r && g > b) return r > 160 ? "lime" : "green";
if (b > r && b > g) return r > 100 ? "violet" : "blue";
if (r > 200 && g > 200) return "yellow";
if (r > 200 && b > 200) return "pink";
if (g > 200 && b > 200) return "cyan";
return "gray";
}
return raw;
}
@@ -0,0 +1,65 @@
.card {
max-width: 100%;
cursor: pointer;
transition: background-color 150ms ease;
margin: 4px 0;
}
.card:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .card:hover {
background-color: var(--mantine-color-dark-5);
}
.thumbnail {
display: block;
width: 100%;
max-height: 320px;
object-fit: cover;
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .thumbnail {
background-color: var(--mantine-color-dark-6);
}
.mention {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
vertical-align: text-bottom;
text-decoration: none;
color: inherit;
border-radius: var(--mantine-radius-sm);
}
:global(.node-integrationMention) .mention {
border-bottom: none !important;
font-weight: 400;
}
.mention:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .mention:hover {
background-color: var(--mantine-color-dark-5);
}
.mentionText {
text-decoration: underline;
text-decoration-color: var(--mantine-color-gray-4);
text-underline-offset: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 340px;
font-weight: 500;
}
.mentionIcon {
flex-shrink: 0;
}
@@ -0,0 +1,473 @@
import { NodeViewWrapper } from "@tiptap/react";
import {
Card,
Group,
Text,
Badge,
Avatar,
Skeleton,
Anchor,
Stack,
Button,
} from "@mantine/core";
import { useCallback, useState, memo } from "react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
import { timeAgo } from "@/lib/time";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-link-view.module.css";
const SLACK_TEXT_CLAMP_LINES = 4;
function SlackMessageCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const meta = unfurlData.metadata ?? {};
const postedAt = meta.ts ? new Date(parseFloat(meta.ts) * 1000) : null;
const text: string = unfurlData.description ?? "";
const isLong =
text.length > 280 || text.split("\n").length > SLACK_TEXT_CLAMP_LINES;
const footer = [
meta.replyCount
? `${meta.replyCount} ${meta.replyCount === 1 ? t("reply") : t("replies")}`
: null,
unfurlData.status,
meta.teamName,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? "?").charAt(0)}
</Avatar>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.author}
</Text>
{postedAt && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(postedAt)}
</Text>
)}
</Group>
{text && (
<Text
size="sm"
lineClamp={expanded ? undefined : SLACK_TEXT_CLAMP_LINES}
style={{ whiteSpace: "pre-wrap" }}
>
{text}
</Text>
)}
{isLong && (
<Text
size="xs"
fw={600}
role="button"
tabIndex={0}
aria-expanded={expanded}
style={{ cursor: "pointer", width: "fit-content" }}
onClick={() => setExpanded((v) => !v)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((v) => !v);
}
}}
>
{expanded ? t("show less") : t("show more")}
</Text>
)}
{footer && (
<Text size="xs" c="dimmed" truncate>
{footer}
</Text>
)}
</Stack>
<Anchor
href={url}
target="_blank"
rel="noopener"
aria-label={t("Open in Slack")}
style={{ flexShrink: 0, lineHeight: 0 }}
>
{getIntegrationIcon("slack", 18)}
</Anchor>
</Group>
</Card>
</NodeViewWrapper>
);
}
function JiraIssueCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
const { t } = useTranslation();
const meta = unfurlData.metadata ?? {};
const infoLine = [
meta.issueKey,
unfurlData.author
? t("Assigned to {{name}}", { name: unfurlData.author })
: t("Unassigned"),
meta.updatedAt
? t("Updated {{time}}", { time: timeAgo(new Date(meta.updatedAt)) })
: null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0 }}>{getIntegrationIcon("jira", 28)}</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap">
{meta.issueTypeIconUrl && (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
style={{ flexShrink: 0 }}
/>
)}
<Text size="xs" c="dimmed" truncate>
{infoLine}
</Text>
</Group>
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("jira", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function FigmaFileCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
const { t } = useTranslation();
// Figma thumbnail links are pre-signed and expire; drop the preview rather
// than render a broken image.
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const meta = unfurlData.metadata ?? {};
const thumbnailUrl: string | undefined = meta.thumbnailUrl;
const showThumbnail = Boolean(thumbnailUrl) && !thumbnailFailed;
const subtitle = [
unfurlData.author
? t("Last modified by {{name}}", { name: unfurlData.author })
: unfurlData.description,
meta.lastModified ? timeAgo(new Date(meta.lastModified)) : null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
{showThumbnail && (
<Card.Section withBorder>
<img
src={thumbnailUrl}
alt=""
loading="lazy"
className={classes.thumbnail}
onError={() => setThumbnailFailed(true)}
/>
</Card.Section>
)}
<Group gap="sm" wrap="nowrap" mt={showThumbnail ? "sm" : undefined}>
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? unfurlData.title ?? "F").charAt(0)}
</Avatar>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
)}
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("figma", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function IntegrationLinkView(props: any) {
const { node } = props;
const { url, provider } = node.attrs;
const { t } = useTranslation();
const unfurl = useUnfurl(url);
const [connecting, setConnecting] = useState(false);
const needsConnection =
unfurl.state === "needsConnection" ? unfurl.needsConnection : null;
const handleConnect = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!needsConnection) return;
setConnecting(true);
try {
const result = await getOAuthAuthorizeUrl({
integrationId: needsConnection.integrationId,
returnPath: window.location.pathname,
});
window.location.href = result.authorizationUrl;
} catch (error) {
setConnecting(false);
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[needsConnection, t],
);
if (needsConnection) {
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm" wrap="nowrap">
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{needsConnection.title}
</Text>
{needsConnection.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{needsConnection.description}
</Text>
)}
</Stack>
<Button
size="xs"
variant="filled"
color="dark"
loading={connecting}
onClick={handleConnect}
style={{ flexShrink: 0 }}
>
{t("Connect to {{name}} to update", {
name: needsConnection.integrationName,
})}
</Button>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state === "loading") {
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm">
<Skeleton circle height={24} />
<Stack gap={4} style={{ flex: 1 }}>
<Skeleton height={14} width="60%" />
<Skeleton height={10} width="80%" />
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state !== "loaded") {
// anonymous or error: a plain link card, no third-party data
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Anchor href={url} target="_blank" rel="noopener" size="sm">
{url}
</Anchor>
</Card>
</NodeViewWrapper>
);
}
const unfurlData = unfurl.data;
// metadata.ts marks legacy message unfurls stored before metadata.type existed.
const slackMeta = provider === "slack" ? unfurlData.metadata : null;
if (slackMeta?.type === "message" || (slackMeta && !slackMeta.type && slackMeta.ts)) {
return <SlackMessageCard url={url} unfurlData={unfurlData} />;
}
if (provider === "jira" && unfurlData.metadata?.issueKey) {
return <JiraIssueCard url={url} unfurlData={unfurlData} />;
}
if (provider === "figma") {
return <FigmaFileCard url={url} unfurlData={unfurlData} />;
}
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar src={unfurlData.authorAvatarUrl} size={28} radius="xl" style={{ flexShrink: 0 }} />
) : (
<div style={{ flexShrink: 0 }}>{getIntegrationIcon(provider, 28)}</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
{unfurlData.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{unfurlData.description}
</Text>
)}
</Stack>
{provider && (
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon(provider, 18)}
</div>
)}
</Group>
</Card>
</NodeViewWrapper>
);
}
export default memo(IntegrationLinkView);
@@ -0,0 +1,162 @@
import { NodeViewWrapper } from "@tiptap/react";
import { Avatar, Badge, Text } from "@mantine/core";
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-link-view.module.css";
function shortUrl(url: string): string {
try {
const parsed = new URL(url);
return `${parsed.host}${parsed.pathname}`;
} catch {
return url;
}
}
function IntegrationMentionView(props: any) {
const { node } = props;
const { url, provider } = node.attrs;
const { t } = useTranslation();
const unfurl = useUnfurl(url);
const data = unfurl.state === "loaded" ? unfurl.data : null;
const meta = data?.metadata ?? {};
const isSlackMessage =
provider === "slack" && (meta.type === "message" || (!meta.type && meta.ts));
const issueNumber = meta.iid ?? meta.number;
const typeLabel =
meta.type === "project"
? t("Project")
: meta.type === "initiative"
? t("Initiative")
: null;
const statusBadge = data?.status ? (
<Badge
size="xs"
variant="light"
color={toBadgeColor(data.statusColor)}
c={badgeTextColor(toBadgeColor(data.statusColor))}
className={classes.mentionIcon}
>
{data.status}
</Badge>
) : null;
let content;
if (!data) {
// anonymous / loading / error / needs-connection: a compact link chip
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>{shortUrl(url)}</span>
</>
);
} else if (isSlackMessage) {
content = (
<>
<Avatar
src={data.authorAvatarUrl}
size={16}
radius="xl"
className={classes.mentionIcon}
>
{(data.author ?? "?").charAt(0)}
</Avatar>
{data.author && (
<Text component="span" size="sm" c="dimmed">
{data.author}
</Text>
)}
<span className={classes.mentionText}>
{(data.description ?? "").split("\n")[0] || shortUrl(url)}
</span>
{getIntegrationIcon("slack", 14)}
{data.status && (
<Badge
size="xs"
variant="light"
color="gray"
c={badgeTextColor("gray")}
tt="none"
className={classes.mentionIcon}
>
{data.status}
</Badge>
)}
</>
);
} else if (meta.issueKey) {
// Jira: type icon leads, provider icon trails.
content = (
<>
{meta.issueTypeIconUrl ? (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
className={classes.mentionIcon}
/>
) : (
getIntegrationIcon(provider, 14)
)}
<Text component="span" size="sm" c="dimmed">
{meta.issueKey}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
{meta.issueTypeIconUrl && getIntegrationIcon(provider, 14)}
</>
);
} else if (issueNumber) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<Text component="span" size="sm" c="dimmed">
#{issueNumber}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else if (typeLabel) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<Text component="span" size="sm" c="dimmed">
{typeLabel}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else {
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>{data.title || shortUrl(url)}</span>
{statusBadge}
</>
);
}
return (
<NodeViewWrapper as="span" style={{ display: "inline" }}>
<a
href={url}
target="_blank"
rel="noopener"
title={url}
className={classes.mention}
>
{content}
</a>
</NodeViewWrapper>
);
}
export default memo(IntegrationMentionView);
@@ -0,0 +1,164 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect } from "react";
import { Button, Paper, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
const INTEGRATION_NODE_TYPES = ["integrationLink", "integrationMention"];
export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const menuState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return null;
return integrationPasteMenuKey.getState(ctx.editor.state) ?? null;
},
});
const findTarget = useCallback(() => {
const state = integrationPasteMenuKey.getState(editor.state);
if (!state) return null;
const node = editor.state.doc.nodeAt(state.pos);
if (!node || !INTEGRATION_NODE_TYPES.includes(node.type.name)) return null;
return { node, pos: state.pos };
}, [editor]);
const shouldShow = useCallback(() => Boolean(findTarget()), [findTarget]);
const getReferencedVirtualElement = useCallback(() => {
const target = findTarget();
if (!target) return undefined;
const dom = editor.view.nodeDOM(target.pos) as HTMLElement | null;
const domRect =
dom?.getBoundingClientRect?.() ??
posToDOMRect(
editor.view,
target.pos,
target.pos + target.node.nodeSize,
);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor, findTarget]);
const dismiss = useCallback(() => {
editor.view.dispatch(
editor.state.tr.setMeta(integrationPasteMenuKey, null),
);
}, [editor]);
useEffect(() => {
if (!menuState) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") dismiss();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [menuState, dismiss]);
const convert = useCallback(
(target: "card" | "mention" | "url") => {
const found = findTarget();
if (!found) {
dismiss();
return;
}
const { node, pos } = found;
const attrs = { ...node.attrs };
const from = pos;
const to = pos + node.nodeSize;
const isBlock = node.type.name === "integrationLink";
// Always replace, even when the node is already in the requested form:
// the menu closes via the doc change, and BubbleMenu never re-evaluates
// on meta-only transactions, so a bare dismiss would leave it stuck.
let content: Record<string, any>;
if (target === "card") {
content = { type: "integrationLink", attrs };
} else if (target === "mention") {
const mention = { type: "integrationMention", attrs };
content = isBlock
? {
type: "paragraph",
content: [mention, { type: "text", text: " " }],
}
: mention;
} else {
const linkText = {
type: "text",
text: attrs.url,
marks: [{ type: "link", attrs: { href: attrs.url } }],
};
content = isBlock
? { type: "paragraph", content: [linkText] }
: linkText;
}
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.deleteRange({ from, to })
.insertContentAt(from, content)
.run();
},
[editor, findTarget, dismiss],
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey="integration-paste-menu"
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{ placement: "bottom-start", flip: true }}
shouldShow={shouldShow}
>
{/* Content is gated on the plugin state too: meta-only dismissals
(Escape) are invisible to BubbleMenu's update cycle. */}
{menuState ? (
<Paper shadow="md" radius="md" withBorder p={4} miw={140}>
<Text size="xs" c="dimmed" px={8} py={4}>
{t("Paste as")}
</Text>
<Stack gap={2}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
fullWidth
justify="flex-start"
onClick={() => convert("card")}
>
{t("Card")}
</Button>
<Button
variant="subtle"
color="gray"
size="compact-sm"
fullWidth
justify="flex-start"
onClick={() => convert("mention")}
>
{t("Mention")}
</Button>
<Button
variant="subtle"
color="gray"
size="compact-sm"
fullWidth
justify="flex-start"
onClick={() => convert("url")}
>
{t("URL")}
</Button>
</Stack>
</Paper>
) : null}
</BaseBubbleMenu>
);
}
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createStore, Provider } from "jotai";
import { ReactNode } from "react";
import { useUnfurl } from "./use-unfurl";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import {
UnfurlNeedsConnection,
UnfurlResult,
} from "@/features/integration/types/integration.types";
vi.mock("@/features/integration/services/integration-service", () => ({
unfurlUrl: vi.fn(),
}));
const mockedUnfurlUrl = vi.mocked(unfurlUrl);
const ISSUE_URL = "https://github.com/acme/repo/issues/42";
const loadedResult: UnfurlResult = {
title: "Fix race condition in file watcher",
url: ISSUE_URL,
provider: "github",
status: "open",
};
const needsConnectionResult: UnfurlNeedsConnection = {
needsConnection: true,
integrationId: "int-1",
integrationType: "github",
integrationName: "GitHub",
title: "GitHub link",
description: "github.com/acme/repo/issues/42",
};
function createWrapper(loggedIn: boolean) {
const store = createStore();
if (loggedIn) {
store.set(currentUserAtom, { user: { id: "user-1" } } as any);
}
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: ReactNode }) => (
<Provider store={store}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Provider>
);
}
describe("useUnfurl", () => {
beforeEach(() => {
localStorage.clear();
vi.resetAllMocks();
});
it("never fetches for anonymous viewers and reports anonymous", () => {
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(false),
});
expect(result.current.state).toBe("anonymous");
expect(mockedUnfurlUrl).not.toHaveBeenCalled();
});
it("starts loading then exposes the unfurl result", async () => {
mockedUnfurlUrl.mockResolvedValue(loadedResult);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
expect(result.current.state).toBe("loading");
await waitFor(() => expect(result.current.state).toBe("loaded"));
expect(
result.current.state === "loaded" && result.current.data,
).toEqual(loadedResult);
expect(mockedUnfurlUrl).toHaveBeenCalledWith({ url: ISSUE_URL });
});
it("maps a needsConnection response without treating it as an error", async () => {
mockedUnfurlUrl.mockResolvedValue(needsConnectionResult);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() =>
expect(result.current.state).toBe("needsConnection"),
);
expect(
result.current.state === "needsConnection" &&
result.current.needsConnection,
).toEqual(needsConnectionResult);
});
it("maps a null result (no matching provider) to error", async () => {
mockedUnfurlUrl.mockResolvedValue(null);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() => expect(result.current.state).toBe("error"));
});
it("maps a rejected request to error", async () => {
mockedUnfurlUrl.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() => expect(result.current.state).toBe("error"));
});
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import {
UnfurlNeedsConnection,
UnfurlResult,
} from "@/features/integration/types/integration.types";
const UNFURL_STALE_TIME = 5 * 60 * 1000; // mirrors the server-side Redis TTL
export type UnfurlState =
| { state: "anonymous" }
| { state: "loading" }
| { state: "error" }
| { state: "needsConnection"; needsConnection: UnfurlNeedsConnection }
| { state: "loaded"; data: UnfurlResult };
// Resolves the unfurl per viewer at render time. Nothing is written back into
// the document, so third-party permissions are enforced on every view:
// unconnected viewers get needsConnection and anonymous viewers never fetch.
export function useUnfurl(url: string): UnfurlState {
const currentUser = useAtomValue(currentUserAtom);
const isAuthenticated = Boolean(currentUser?.user);
const query = useQuery({
queryKey: ["unfurl", url],
queryFn: () => unfurlUrl({ url }),
enabled: isAuthenticated && Boolean(url),
staleTime: UNFURL_STALE_TIME,
retry: false,
});
if (!isAuthenticated || !url) {
return { state: "anonymous" };
}
if (query.isPending) {
return { state: "loading" };
}
if (query.isError || !query.data) {
return { state: "error" };
}
if ("needsConnection" in query.data) {
return { state: "needsConnection", needsConnection: query.data };
}
return { state: "loaded", data: query.data };
}
@@ -10,6 +10,7 @@ import {
Paper,
ScrollArea,
Text,
Tooltip,
UnstyledButton,
VisuallyHidden,
} from "@mantine/core";
@@ -18,6 +19,7 @@ import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
const CommandList = ({
items,
@@ -37,11 +39,12 @@ const CommandList = ({
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
const hasBases = useHasFeature(Feature.BASES);
// Title must match the "Base (Inline)" item in menu-items.ts. Without the
// bases entitlement the item stays visible but disabled; an expired license
// the client can't detect falls through to a handled create failure.
const upgradeLabel = useUpgradeLabel();
// Without the bases entitlement the item stays visible but inert; an
// expired license the client can't detect falls through to a handled
// create failure.
const isItemDisabled = (item: SlashMenuItemType) =>
!hasBases && item.title === "Base (Inline)";
!hasBases && item.requiresBases === true;
const flatItems = useMemo(() => {
return Object.values(items).flat();
@@ -152,18 +155,22 @@ const CommandList = ({
const itemIndex = flatIndex;
const disabled = isItemDisabled(item);
return (
<Tooltip
key={itemIndex}
label={upgradeLabel}
disabled={!disabled}
position="right"
>
<UnstyledButton
data-item-index={itemIndex}
key={itemIndex}
id={`slash-command-option-${itemIndex}`}
role="option"
aria-selected={itemIndex === selectedIndex}
aria-disabled={disabled}
disabled={disabled}
onClick={() => selectItem(itemIndex)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: itemIndex === selectedIndex,
[classes.disabledItem]: disabled,
[classes.gatedItem]: disabled,
})}
>
<Group wrap="nowrap">
@@ -188,6 +195,7 @@ const CommandList = ({
)}
</Group>
</UnstyledButton>
</Tooltip>
);
})}
</div>
@@ -30,6 +30,7 @@ import {
IconTag,
IconMoodSmile,
IconRotate2,
IconSuperscript,
} from "@tabler/icons-react";
import {
CommandProps,
@@ -177,6 +178,16 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(),
},
{
title: "Footnote",
description: "Insert a footnote reference.",
searchTerms: ["footnote", "reference", "citation", "note"],
icon: IconSuperscript,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
editor.commands.addFootnote();
},
},
{
title: "Image",
description: "Upload any image from your device.",
@@ -366,6 +377,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
description: "Insert an inline base on this page",
searchTerms: ["base", "database", "table", "grid", "spreadsheet"],
icon: IconTable,
requiresBases: true,
command: ({ editor, range }: CommandProps) => {
insertBaseEmbedBlock(editor, { range });
},
@@ -375,6 +387,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
description: "Insert a kanban board on this page",
searchTerms: ["kanban", "board", "cards", "status", "task", "database"],
icon: IconLayoutKanban,
requiresBases: true,
command: ({ editor, range }: CommandProps) => {
insertBaseEmbedBlock(editor, { range, template: "kanban" });
},
@@ -26,7 +26,6 @@
}
}
.disabledItem {
opacity: 0.45;
cursor: not-allowed;
.gatedItem {
opacity: 0.7;
}
@@ -21,6 +21,7 @@ export type SlashMenuItemType = {
searchTerms: string[];
command: (props: CommandProps) => void;
disable?: (editor: ReturnType<typeof useEditor>) => boolean;
requiresBases?: true;
};
export type SlashMenuGroupedItemsType = {
@@ -40,6 +40,7 @@ export default function TransclusionContent({ content }: Props) {
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content as any}
/>
@@ -1,9 +1,11 @@
import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
import { CharacterCount, UndoRedo } from "@tiptap/extensions";
import { Placeholder } from "@/features/editor/extensions/placeholder";
import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography";
@@ -55,6 +57,8 @@ import {
Indent,
UniqueID,
SharedStorage,
IntegrationLink,
IntegrationMention,
Columns,
Column,
Status,
@@ -62,6 +66,9 @@ import {
TransclusionReference,
TableView,
BaseEmbed as BaseEmbedNode,
Footnotes,
Footnote,
FootnoteReference,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -90,6 +97,9 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx";
import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx";
import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx";
@@ -131,6 +141,7 @@ lowlight.register("scala", scala);
// @ts-ignore
export const mainExtensions = [
StarterKit.configure({
document: false,
heading: false,
undoRedo: false,
link: false,
@@ -142,6 +153,9 @@ export const mainExtensions = [
codeBlock: false,
code: false,
}),
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
@@ -194,16 +208,19 @@ export const mainExtensions = [
return i18n.t("Toggle title");
}
if (node.type.name === "paragraph") {
const $pos = editor.state.doc.resolve(pos);
const parentName = $pos.parent.type.name;
if (
parentName === "column" ||
parentName === "tableCell" ||
parentName === "tableHeader" ||
parentName === "callout" ||
parentName === "blockquote"
) {
return i18n.t("Write...");
const doc = editor.state.doc;
if (pos >= 0 && pos <= doc.content.size) {
const parentName = doc.resolve(pos).parent.type.name;
if (
parentName === "column" ||
parentName === "tableCell" ||
parentName === "tableHeader" ||
parentName === "callout" ||
parentName === "blockquote" ||
parentName === "footnote"
) {
return i18n.t("Write...");
}
}
return i18n.t('Write anything. Enter "/" for commands');
}
@@ -375,6 +392,13 @@ export const mainExtensions = [
Subpages.configure({
view: SubpagesView,
}),
IntegrationLink.configure({
view: IntegrationLinkView,
}),
IntegrationMention.configure({
view: IntegrationMentionView,
}),
IntegrationPasteMenuExtension,
Status.configure({
view: StatusView,
}),
@@ -414,6 +438,9 @@ export const mainExtensions = [
}).configure(),
Columns,
Column,
Footnotes,
Footnote,
FootnoteReference,
AutoJoiner.configure({
elementsToJoin: [],
}),
@@ -0,0 +1,44 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export type IntegrationPasteMenuState = { pos: number } | null;
export const integrationPasteMenuKey = new PluginKey<IntegrationPasteMenuState>(
"integrationPasteMenu",
);
// Holds the position of a just-pasted integration node so the "Paste as"
// menu can anchor to it. Any other edit or selection change dismisses it.
export const IntegrationPasteMenuExtension = Extension.create({
name: "integrationPasteMenu",
addProseMirrorPlugins() {
return [
new Plugin({
key: integrationPasteMenuKey,
state: {
init: (): IntegrationPasteMenuState => null,
apply(tr, prev): IntegrationPasteMenuState {
const meta = tr.getMeta(integrationPasteMenuKey);
if (meta !== undefined) return meta;
if (!prev) return null;
// Clicking or typing elsewhere dismisses.
if (tr.selectionSet) return null;
// Structural follow-ups (unique-id assignment, trailing node)
// keep the menu anchored: remap and re-validate the position.
if (tr.docChanged) {
const pos = tr.mapping.map(prev.pos);
const node = tr.doc.nodeAt(pos);
const isIntegrationNode =
node &&
(node.type.name === "integrationLink" ||
node.type.name === "integrationMention");
return isIntegrationNode ? { pos } : null;
}
return prev;
},
},
}),
];
},
});
@@ -0,0 +1,64 @@
import { isNodeEmpty } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { Placeholder as TiptapPlaceholder } from "@tiptap/extensions";
export const Placeholder = TiptapPlaceholder.extend({
addProseMirrorPlugins() {
const editor = this.editor;
const options = this.options;
const dataAttribute = `data-${options.dataAttribute || "placeholder"}`;
return [
new Plugin({
key: new PluginKey("docmostPlaceholder"),
props: {
decorations: (state) => {
if (options.showOnlyWhenEditable && !editor.isEditable) {
return null;
}
const { doc, selection } = state;
const { anchor } = selection;
const decorations: Decoration[] = [];
const isEmptyDoc = editor.isEmpty;
doc.descendants((node, pos) => {
if (!node.type.isTextblock) {
return options.includeChildren;
}
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize;
const isEmpty = !node.isLeaf && isNodeEmpty(node);
if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {
const emptyNodeClass =
typeof options.emptyNodeClass === "function"
? options.emptyNodeClass({ editor, node, pos, hasAnchor })
: options.emptyNodeClass;
const classes = [emptyNodeClass];
if (isEmptyDoc) {
classes.push(options.emptyEditorClass);
}
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(" "),
[dataAttribute]:
typeof options.placeholder === "function"
? options.placeholder({ editor, node, pos, hasAnchor })
: options.placeholder,
}),
);
}
return options.includeChildren;
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
@@ -1,7 +0,0 @@
import { getCollaborationUrl } from "@/lib/config.ts";
const useCollaborationURL = (): string => {
return getCollaborationUrl();
};
export default useCollaborationURL;
+188 -158
View File
@@ -2,20 +2,22 @@ import "@/features/editor/styles/index.css";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import {
HocuspocusProviderWebsocketComponent,
HocuspocusRoom,
useHocuspocusEvent,
useHocuspocusProvider,
} from "@hocuspocus/provider-react";
import {
Editor,
EditorContent,
@@ -28,7 +30,6 @@ import {
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
@@ -74,8 +75,15 @@ import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx";
import { getInstalledIntegrations } from "@/features/integration/services/integration-service";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next";
import {
acquireCollabSocket,
getCollabSocket,
releaseCollabSocket,
} from "@/features/editor/collab-socket";
interface PageEditorProps {
pageId: string;
@@ -91,7 +99,80 @@ export default function PageEditor({
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const collaborationURL = useCollaborationUrl();
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const [socket] = useState(getCollabSocket);
const hasCollabToken = !!collabQuery?.token;
useEffect(() => {
if (!hasCollabToken) return;
acquireCollabSocket();
return () => releaseCollabSocket();
}, [hasCollabToken]);
const handleStateless = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const handleAuthenticationFailed = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
};
return (
<TransclusionLookupProvider>
{collabQuery?.token ? (
<HocuspocusProviderWebsocketComponent websocketProvider={socket}>
<HocuspocusRoom
name={`page.${pageId}`}
token={collabQuery.token}
flushDelay={500}
onStateless={handleStateless}
onAuthenticationFailed={handleAuthenticationFailed}
>
<CollabPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</HocuspocusRoom>
</HocuspocusProviderWebsocketComponent>
) : (
<StaticPageEditor content={content} ariaLabel={t("Page content")} />
)}
</TransclusionLookupProvider>
);
}
function CollabPageEditor({
pageId,
editable,
content,
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const provider = useHocuspocusProvider();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
@@ -112,7 +193,6 @@ export default function PageEditor({
);
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
@@ -123,95 +203,24 @@ export default function PageEditor({
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
}
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
const local = new IndexeddbPersistence(
provider.configuration.name,
provider.document,
);
local.on("synced", () => setIsLocalSynced(true));
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
local.destroy();
};
}, [pageId]);
}, [provider]);
useHocuspocusEvent("synced", ({ state }) => setIsRemoteSynced(state));
useHocuspocusEvent("status", ({ status }) => setYjsConnectionStatus(status));
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
const socket = provider.configuration.websocketProvider;
if (
isIdle &&
@@ -228,28 +237,21 @@ export default function PageEditor({
resetIdle();
socket.connect();
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
}, [isIdle, documentState, provider, resetIdle]);
const extensions = useMemo(() => {
if (!providersReady || !providersRef.current || !currentUser?.user) {
if (!currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [providersReady, currentUser?.user]);
return [...mainExtensions, ...collabExtensions(provider, currentUser.user)];
}, [provider, currentUser?.user]);
const editor = useEditor(
{
extensions,
editable,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -326,6 +328,25 @@ export default function PageEditor({
[pageId, editable, extensions],
);
useEffect(() => {
// Warm the cache the paste handler reads to decide whether a pasted
// integration url becomes a card or stays an ordinary link.
queryClient.prefetchQuery({
queryKey: ["installed-integrations"],
queryFn: getInstalledIntegrations,
});
}, []);
useLayoutEffect(() => {
if (editor && !editor.isDestroyed) {
// @ts-ignore
setEditor(editor);
// @ts-ignore
editor.storage.pageId = pageId;
editorRef.current = editor;
}
}, [editor, pageId, setEditor]);
const editorIsEditable = useEditorState({
editor,
selector: (ctx) => {
@@ -416,65 +437,74 @@ export default function PageEditor({
}
}, [yjsConnectionStatus, isSynced]);
if (showStatic) {
return <StaticPageEditor content={content} ariaLabel={t("Page content")} />;
}
return (
<TransclusionLookupProvider>
{showStatic ? (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": t("Page content"),
},
}}
/>
) : (
<div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
<div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && editorIsEditable && (
<div>
<EditorAiMenu editor={editor} />
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableHandlesLayer editor={editor} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<PdfMenu editor={editor} />
<CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
</div>
)}
{editor &&
!editorIsEditable &&
(editable || canComment) &&
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
)}
{editor && editorIsEditable && (
<div>
<EditorAiMenu editor={editor} />
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableHandlesLayer editor={editor} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<PdfMenu editor={editor} />
<CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
<IntegrationPasteMenu editor={editor} />
</div>
<div
onClick={() => {
if (editor && !editor.isDestroyed) editor.commands.focus("end");
}}
style={{ paddingBottom: "20vh" }}
></div>
</div>
)}
</TransclusionLookupProvider>
)}
{editor && !editorIsEditable && (editable || canComment) && (
<ReadonlyBubbleMenu editor={editor} />
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
)}
</div>
<div
onClick={() => {
if (editor && !editor.isDestroyed) editor.commands.focus("end");
}}
style={{ paddingBottom: "20vh" }}
></div>
</div>
);
}
function StaticPageEditor({
content,
ariaLabel,
}: {
content: any;
ariaLabel: string;
}) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
);
}
@@ -85,6 +85,7 @@ export default function ReadonlyPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={titleExtensions}
content={title}
></EditorProvider>
@@ -93,6 +94,7 @@ export default function ReadonlyPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content}
onCreate={({ editor }) => {
@@ -0,0 +1,26 @@
.ProseMirror sup a.footnote-ref {
color: var(--mantine-primary-color-filled);
text-decoration: none;
cursor: pointer;
font-weight: 600;
}
.ProseMirror sup:has(a.footnote-ref) {
padding: 0 1px;
}
.ProseMirror ol.footnotes {
margin-top: 2rem;
padding-top: 0.75rem;
font-size: 0.875rem;
color: var(--mantine-color-dimmed);
list-style-type: decimal;
}
.ProseMirror ol.footnotes:has(li) {
border-top: 1px solid var(--mantine-color-default-border);
}
.ProseMirror ol.footnotes li p {
margin: 0.15rem 0;
}
@@ -18,3 +18,4 @@
@import "./columns.css";
@import "./status.css";
@import "./base-embed.css";
@import "./footnotes.css";
@@ -54,7 +54,7 @@
var(--mantine-color-dark-5)
);
font-weight: bold;
text-align: left;
text-align: start;
}
.column-resize-handle {
@@ -86,6 +86,7 @@ export function TitleEditor({
},
editable: editable,
content: title,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -0,0 +1,108 @@
import { Group, Text, Button, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IntegrationDefinition, UserConnection } from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
type ConnectionRowProps = {
definition: IntegrationDefinition;
connection?: UserConnection;
onConnect: (type: string) => void;
onDisconnect: (integrationId: string) => void;
disconnectingId?: string;
};
export default function ConnectionRow({
definition,
connection,
onConnect,
onDisconnect,
disconnectingId,
}: ConnectionRowProps) {
const { t } = useTranslation();
const isWorkspaceScoped = definition.oauth?.connectionScope === 'workspace';
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
{getIntegrationIcon(definition.type, 28)}
<div>
<Text size="sm" fw={500}>
{definition.name}
</Text>
<Text size="xs" c="dimmed">
{definition.description}
</Text>
</div>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{isWorkspaceScoped ? (
<>
{connection ? (
<Text size="xs" c="green">
{t("Linked")}
{connection.providerUserId && ` as @${connection.providerUserId}`}
</Text>
) : (
<Text size="xs" c="dimmed">
{t("Use")} <code>/docmost help</code> {t("in")} {definition.name} {t("to link your account.")}
</Text>
)}
</>
) : (
<>
{connection ? (
<>
{connection.invalidatedAt ? (
<>
<Text size="xs" c="orange">
{t("Connection expired")}
</Text>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => onConnect(definition.type)}
>
{t("Reconnect")}
</Button>
</>
) : (
<Text size="xs" c="green">
{t("Connected")}
{connection.providerUserId && ` (${connection.providerUserId})`}
</Text>
)}
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onDisconnect(connection.integrationId)}
loading={disconnectingId === connection.integrationId}
>
{t("Disconnect")}
</Button>
</>
) : (
<Button
size="xs"
variant="light"
onClick={() => onConnect(definition.type)}
>
{t("Connect")}
</Button>
)}
</>
)}
</Group>
</Group>
</Box>
);
}
@@ -0,0 +1,30 @@
import { ReactNode } from "react";
import {
FigmaIcon,
GithubIcon,
GitlabIcon,
GoogleDocsIcon,
JiraIcon,
LinearIcon,
SlackIcon,
} from "@/components/icons";
import { IconPuzzle } from "@tabler/icons-react";
const integrationIconMap: Record<string, (size: number) => ReactNode> = {
github: (size) => <GithubIcon size={size} />,
gitlab: (size) => <GitlabIcon size={size} />,
slack: (size) => <SlackIcon size={size} />,
linear: (size) => <LinearIcon size={size} />,
jira: (size) => <JiraIcon size={size} />,
figma: (size) => <FigmaIcon size={size} />,
google_docs: (size) => <GoogleDocsIcon size={size} />,
};
export function getIntegrationIcon(
type: string,
size: number,
): ReactNode {
const renderIcon = integrationIconMap[type];
if (renderIcon) return renderIcon(size);
return <IconPuzzle size={size} stroke={1.5} />;
}
@@ -0,0 +1,66 @@
import { Box, Group, Skeleton, Stack } from "@mantine/core";
const TITLE_WIDTHS = [64, 52, 60, 44, 58, 96, 56];
const DESCRIPTION_WIDTHS = [300, 250, 320, 180, 290, 270, 260];
type IntegrationListSkeletonProps = {
rows?: number;
withBadges?: boolean;
};
export default function IntegrationListSkeleton({
rows = 7,
withBadges = true,
}: IntegrationListSkeletonProps) {
return (
<Stack gap={0} aria-hidden="true">
{Array.from({ length: rows }, (_, index) => (
<Box
key={index}
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Skeleton height={28} circle style={{ flexShrink: 0 }} />
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap" h={20}>
<Skeleton
height={12}
width={TITLE_WIDTHS[index % TITLE_WIDTHS.length]}
radius="xs"
/>
{withBadges && (
<>
<Skeleton height={16} width={52} radius="xl" />
<Skeleton height={16} width={52} radius="xl" />
</>
)}
</Group>
<Group h={17}>
<Skeleton
height={10}
width={
DESCRIPTION_WIDTHS[index % DESCRIPTION_WIDTHS.length]
}
maw="100%"
radius="xs"
/>
</Group>
</Stack>
</Group>
<Skeleton
height={30}
width={64}
radius="sm"
style={{ flexShrink: 0 }}
/>
</Group>
</Box>
))}
</Stack>
);
}
@@ -0,0 +1,98 @@
import {
Group,
Text,
Badge,
Button,
Box,
Stack,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
IntegrationDefinition,
Integration,
} from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
type IntegrationRowProps = {
definition: IntegrationDefinition;
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
};
export default function IntegrationRow({
definition,
installation,
onInstall,
onUninstall,
}: IntegrationRowProps) {
const { t } = useTranslation();
const isInstalled = !!installation;
const hasAccess = useHasFeature(Feature.INTEGRATIONS);
const locked = !!definition.requiresLicense && !hasAccess;
const upgradeLabel = useUpgradeLabel();
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{getIntegrationIcon(definition.type, 28)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{definition.name}
</Text>
{locked && (
<Badge size="xs" variant="light" color="violet">
{t("Paid")}
</Badge>
)}
{definition.capabilities.map((cap) => (
<Badge key={cap} size="xs" variant="light">
{cap}
</Badge>
))}
</Group>
<Text size="xs" c="dimmed" truncate>
{definition.description}
</Text>
</Stack>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{isInstalled ? (
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onUninstall(installation.id)}
>
{t("Uninstall")}
</Button>
) : (
<Tooltip label={upgradeLabel} disabled={!locked}>
<Button
size="xs"
variant="light"
disabled={locked}
onClick={() => onInstall(definition.type)}
>
{t("Install")}
</Button>
</Tooltip>
)}
</Group>
</Group>
</Box>
);
}
@@ -0,0 +1,111 @@
import { Text, Alert, Stack } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import ConnectionRow from "../components/connection-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useMyConnections,
useDisconnectIntegration,
} from "../queries/integration-query";
import * as integrationService from "../services/integration-service";
export default function Connections() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const { data: myConnections, isLoading: loadingConnections } =
useMyConnections();
const disconnectMutation = useDisconnectIntegration();
const isLoading = loadingAvailable || loadingInstalled || loadingConnections;
const handleConnect = async (type: string) => {
const integration = installed?.find((i) => i.type === type);
if (!integration) return;
try {
const result = await integrationService.getOAuthAuthorizeUrl({
integrationId: integration.id,
});
window.location.href = result.authorizationUrl;
} catch (error) {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to start OAuth connection"),
color: "red",
});
}
};
const handleDisconnect = (integrationId: string) => {
disconnectMutation.mutate({ integrationId });
};
// Only the row being disconnected shows a loader; isPending alone is shared by every row.
const disconnectingId = disconnectMutation.isPending
? disconnectMutation.variables?.integrationId
: undefined;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Connections")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Connections")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage the apps you have connected to your account.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton rows={3} withBadges={false} />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available
.filter((def) => {
if (!def.capabilities.includes("oauth")) return false;
return installed?.some((i) => i.type === def.type);
})
.map((def) => {
const connection = myConnections?.find(
(c) => c.type === def.type,
);
return (
<ConnectionRow
key={def.type}
definition={def}
connection={connection}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
disconnectingId={disconnectingId}
/>
);
})}
</Stack>
)}
</>
);
}
@@ -0,0 +1,137 @@
import { Text, Alert, Stack } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import IntegrationRow from "../components/integration-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useInstallIntegration,
useUninstallIntegration,
} from "../queries/integration-query";
import {
getOAuthAuthorizeUrl,
getOAuthInstallUrl,
} from "../services/integration-service";
import { Integration } from "../types/integration.types";
import { notifications } from "@mantine/notifications";
export default function Integrations() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const installMutation = useInstallIntegration();
const uninstallMutation = useUninstallIntegration();
const handleInstall = useCallback(
async (type: string) => {
const definition = available?.find((d) => d.type === type);
// Workspace-scoped (Slack): the install row is only persisted when the
// OAuth callback succeeds. Skip the upfront install API call entirely.
if (definition?.oauth?.connectionScope === "workspace") {
try {
const { authorizationUrl } = await getOAuthInstallUrl({ type });
window.location.href = authorizationUrl;
} catch (err: any) {
notifications.show({
message:
err?.response?.data?.message ?? t("Failed to start installation"),
color: "red",
});
}
return;
}
// Per-user OAuth providers (GitLab, Jira, GitHub, ...): create the
// integration row, then send the installing admin straight into their
// own OAuth so they leave with a working connection. Other members
// connect for themselves from /settings/account/connections.
let integration: Integration;
try {
integration = await installMutation.mutateAsync({ type });
} catch {
return; // the mutation reports its own failure
}
if (!definition?.capabilities?.includes("oauth")) return;
try {
const { authorizationUrl } = await getOAuthAuthorizeUrl({
integrationId: integration.id,
returnPath: "/settings/integrations",
});
window.location.href = authorizationUrl;
} catch (err: any) {
// The integration stays installed; the admin can connect later.
notifications.show({
message:
err?.response?.data?.message ??
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[installMutation, available, t],
);
const handleUninstall = useCallback(
(integrationId: string) => {
uninstallMutation.mutate({ integrationId });
},
[uninstallMutation],
);
const isLoading = loadingAvailable || loadingInstalled;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Integrations")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Integrations")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage workspace integrations.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available.map((def) => {
const installation = installed?.find((i) => i.type === def.type);
return (
<IntegrationRow
key={def.type}
definition={def}
installation={installation}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
);
})}
</Stack>
)}
</>
);
}
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Alert, Button, Card, Group, Loader, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import {
decodeSlackLinkState,
confirmSlackLink,
SlackLinkStateInfo,
} from "../services/slack-link-service";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import APP_ROUTE from "@/lib/app-route";
export default function SlackLinkPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const state = searchParams.get("state");
const currentUser = useAtomValue(currentUserAtom);
const [info, setInfo] = useState<SlackLinkStateInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
useEffect(() => {
if (!currentUser) {
const redirectPath = window.location.pathname + window.location.search;
navigate(`${APP_ROUTE.AUTH.LOGIN}?redirect=${encodeURIComponent(redirectPath)}`);
return;
}
if (!state) {
setError(t("Missing state parameter"));
return;
}
decodeSlackLinkState(state)
.then(setInfo)
.catch((e) => setError(e?.response?.data?.message ?? e.message));
}, [state, t, currentUser, navigate]);
async function onConfirm() {
if (!state) return;
setSubmitting(true);
try {
await confirmSlackLink(state);
setDone(true);
} catch (e: any) {
setError(e?.response?.data?.message ?? e.message);
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Stack>
<Text fw={600}>{t("Connected")}</Text>
<Text c="dimmed">{t("You can close this tab and return to Slack.")}</Text>
</Stack>
</Card>
);
}
if (error) {
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Alert color="red" title={t("Could not link account")}>
{error}
</Alert>
</Card>
);
}
if (!info || !currentUser) {
return (
<div style={{ display: "flex", justifyContent: "center", marginTop: 80 }}>
<Loader />
</div>
);
}
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Stack>
<Text fw={600}>{t("Link your Docmost account to Slack")}</Text>
<Text>
{t("Connect Docmost account")}{" "}
<b>{currentUser.user.email}</b>{" "}
{t("to Slack user")} <b>@{info.slackUserName}</b>
{info.slackTeamName && (
<>
{" "}
{t("in")} <b>{info.slackTeamName}</b>
</>
)}
?
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => window.close()}>
{t("Cancel")}
</Button>
<Button onClick={onConfirm} loading={submitting}>
{t("Confirm")}
</Button>
</Group>
</Stack>
</Card>
);
}
@@ -0,0 +1,100 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import * as integrationService from "../services/integration-service";
export function useAvailableIntegrations() {
return useQuery({
queryKey: ["available-integrations"],
queryFn: integrationService.getAvailableIntegrations,
});
}
export function useInstalledIntegrations() {
return useQuery({
queryKey: ["installed-integrations"],
queryFn: integrationService.getInstalledIntegrations,
});
}
export function useInstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.installIntegration,
onSuccess: () => {
notifications.show({ message: t("Integration installed successfully") });
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to install integration"),
color: "red",
});
},
});
}
export function useUninstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.uninstallIntegration,
onSuccess: () => {
notifications.show({
message: t("Integration uninstalled successfully"),
});
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to uninstall integration"),
color: "red",
});
},
});
}
export function useMyConnections() {
return useQuery({
queryKey: ["my-connections"],
queryFn: integrationService.getMyConnections,
});
}
export function useConnectionStatus(integrationId: string | undefined) {
return useQuery({
queryKey: ["integration-connection", integrationId],
queryFn: () =>
integrationService.getConnectionStatus({
integrationId: integrationId!,
}),
enabled: !!integrationId,
});
}
export function useDisconnectIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.disconnectIntegration,
onSuccess: (_data, variables) => {
notifications.show({ message: t("Integration disconnected") });
qc.invalidateQueries({
queryKey: ["integration-connection", variables.integrationId],
});
qc.invalidateQueries({ queryKey: ["my-connections"] });
// removeQueries, not invalidate: refetchOnMount false leaves invalidated inactive queries unrefreshed
qc.removeQueries({ queryKey: ["unfurl"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to disconnect integration"),
color: "red",
});
},
});
}
@@ -0,0 +1,92 @@
import api from "@/lib/api-client";
import {
IntegrationDefinition,
Integration,
ConnectionStatus,
UserConnection,
UnfurlResult,
UnfurlNeedsConnection,
} from "../types/integration.types";
export async function getAvailableIntegrations(): Promise<
IntegrationDefinition[]
> {
const req = await api.post<IntegrationDefinition[]>(
"/integrations/available",
);
return req.data;
}
export async function getInstalledIntegrations(): Promise<Integration[]> {
const req = await api.post<Integration[]>("/integrations/list");
return req.data;
}
export async function installIntegration(data: {
type: string;
}): Promise<Integration> {
const req = await api.post<Integration>("/integrations/install", data);
return req.data;
}
export async function uninstallIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/uninstall", data);
}
export async function getMyConnections(): Promise<UserConnection[]> {
const req = await api.post<UserConnection[]>("/integrations/connections/mine");
return req.data;
}
export async function getConnectionStatus(data: {
integrationId: string;
}): Promise<ConnectionStatus> {
const req = await api.post<ConnectionStatus>(
"/integrations/connection/status",
data,
);
return req.data;
}
export async function getOAuthAuthorizeUrl(data: {
integrationId: string;
returnPath?: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/authorize",
data,
);
return req.data;
}
/**
* For workspace-scoped providers: returns the authorize URL WITHOUT creating
* the integration row. The row is created atomically when the OAuth callback
* succeeds; a cancelled OAuth leaves no half-installed state.
*/
export async function getOAuthInstallUrl(data: {
type: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/install",
data,
);
return req.data;
}
export async function disconnectIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/oauth/disconnect", data);
}
export async function unfurlUrl(data: {
url: string;
}): Promise<UnfurlResult | UnfurlNeedsConnection | null> {
const req = await api.post<{
data: UnfurlResult | UnfurlNeedsConnection | null;
}>("/integrations/unfurl", data);
return req.data.data;
}
@@ -0,0 +1,23 @@
import api from "@/lib/api-client";
export type SlackLinkStateInfo = {
slackUserName: string;
slackUserId: string;
slackTeamId: string;
slackTeamName: string | null;
integrationWorkspaceId: string | undefined;
};
export async function decodeSlackLinkState(
state: string,
): Promise<SlackLinkStateInfo> {
const req = await api.post<SlackLinkStateInfo>(
"/integrations/slack/link/state",
{ state },
);
return req.data;
}
export async function confirmSlackLink(state: string): Promise<void> {
await api.post("/integrations/slack/link", { state });
}
@@ -0,0 +1,65 @@
export type IntegrationCapability = "oauth" | "unfurl" | "actions" | "webhooks";
export type OAuthConfig = {
authUrl: string;
tokenUrl: string;
scopes: string[];
connectionScope?: 'workspace' | 'user';
};
export type IntegrationDefinition = {
type: string;
name: string;
description: string;
icon: string;
capabilities: IntegrationCapability[];
oauth?: OAuthConfig;
requiresLicense?: boolean;
};
export type Integration = {
id: string;
workspaceId: string;
type: string;
settings: Record<string, any> | null;
installedById: string | null;
createdAt: string;
updatedAt: string;
};
export type ConnectionStatus = {
connected: boolean;
providerUserId?: string;
};
export type UserConnection = {
integrationId: string;
type: string;
providerUserId: string | null;
connectedAt: string;
invalidatedAt: string | null;
};
export type UnfurlResult = {
title: string;
description?: string;
url: string;
provider: string;
providerIcon?: string;
status?: string;
statusColor?: string;
author?: string;
authorAvatarUrl?: string;
metadata?: Record<string, any>;
};
// Returned when the link's provider needs a per-user connection the
// requesting user has not authorized yet.
export type UnfurlNeedsConnection = {
needsConnection: true;
integrationId: string;
integrationType: string;
integrationName: string;
title: string;
description?: string;
};
@@ -6,4 +6,13 @@ 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);
export const diffCountsAtom = atom<DiffCounts | null>(
null as DiffCounts | null,
);
export type ComparePair = { newerId: string; olderId: string };
export const compareModeAtom = atom<boolean>(false);
export const compareSelectionAtom = atom<string[]>([]);
export const comparePairAtom = atom<ComparePair | null>(
null as ComparePair | null,
);
@@ -1,7 +1,7 @@
.history {
display: block;
display: flex;
align-items: center;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
@@ -12,6 +12,28 @@
}
}
.historyButton {
flex: 1;
min-width: 0;
color: inherit;
}
.compareCheckbox {
padding-left: var(--mantine-spacing-xs);
}
.itemMenu {
opacity: 0;
margin-right: var(--mantine-spacing-xs);
}
.history:hover .itemMenu,
.history:focus-within .itemMenu,
.history.active .itemMenu,
.itemMenu[aria-expanded="true"] {
opacity: 1;
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
@@ -77,3 +99,8 @@
flex: 1;
padding: rem(16px) rem(40px);
}
.compareBanner {
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}

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