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
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
181 changed files with 11378 additions and 3028 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.
+2 -2
View File
@@ -1,7 +1,7 @@
FROM node:26-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@11.13.0
RUN npm install -g pnpm@11.15.1
FROM base AS builder
@@ -42,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
+5 -5
View File
@@ -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",
@@ -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.",
@@ -1289,5 +1306,16 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} 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}}"
}
+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")} />
+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,
@@ -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 />
@@ -12,6 +12,7 @@ import {
IconMathFunction,
IconRotate2,
IconSitemap,
IconSuperscript,
IconTable,
IconTag,
} from "@tabler/icons-react";
@@ -270,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 };
}
@@ -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.",
@@ -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);
},
},
}),
];
},
});
@@ -2,6 +2,7 @@ import "@/features/editor/styles/index.css";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -74,6 +75,8 @@ 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 {
@@ -100,11 +103,13 @@ export default function PageEditor({
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 {
@@ -246,6 +251,7 @@ function CollabPageEditor({
{
extensions,
editable,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -322,6 +328,25 @@ function CollabPageEditor({
[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) => {
@@ -440,6 +465,7 @@ function CollabPageEditor({
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
<IntegrationPasteMenu editor={editor} />
</div>
)}
{editor && !editorIsEditable && (editable || canComment) && (
@@ -471,6 +497,7 @@ function StaticPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={mainExtensions}
content={content}
editorProps={{
@@ -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));
}
@@ -31,6 +31,7 @@ export function HistoryEditor({
const editor = useEditor({
extensions: mainExtensions,
editable: false,
textDirection: "auto",
});
useEffect(() => {
@@ -170,7 +171,6 @@ export function HistoryEditor({
}
const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({
@@ -1,10 +1,21 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import {
Text,
Group,
UnstyledButton,
Avatar,
Tooltip,
ActionIcon,
Checkbox,
Menu,
} from "@mantine/core";
import { IconDots } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
const MAX_VISIBLE_AVATARS = 5;
@@ -15,6 +26,13 @@ interface HistoryItemProps {
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean;
compareMode: boolean;
isChecked: boolean;
isCheckboxDisabled: boolean;
canCompare: boolean;
onToggleCompare: (id: string) => void;
onStartCompare: (id: string) => void;
onRestore?: (id: string, index: number) => void;
}
const HistoryItem = memo(function HistoryItem({
@@ -24,10 +42,24 @@ const HistoryItem = memo(function HistoryItem({
onHover,
onHoverEnd,
isActive,
compareMode,
isChecked,
isCheckboxDisabled,
canCompare,
onToggleCompare,
onStartCompare,
onRestore,
}: HistoryItemProps) {
const { t } = useTranslation();
const date = formattedDate(new Date(historyItem.createdAt));
const handleClick = useCallback(() => {
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
if (compareMode) {
onToggleCompare(historyItem.id);
} else {
onSelect(historyItem.id, index);
}
}, [compareMode, onToggleCompare, onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index);
@@ -37,63 +69,115 @@ const HistoryItem = memo(function HistoryItem({
const hasContributors = contributors && contributors.length > 0;
return (
<UnstyledButton
p="xs"
onClick={handleClick}
<div
className={clsx(classes.history, { [classes.active]: isActive })}
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
>
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
{compareMode && (
<Checkbox
size="xs"
className={classes.compareCheckbox}
checked={isChecked}
disabled={isCheckboxDisabled}
onChange={() => onToggleCompare(historyItem.id)}
aria-label={t("Select version from {{date}}", { date })}
/>
)}
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
<Tooltip key={contributor.id} label={contributor.name} withArrow>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
<div key={c.id}>{c.name}</div>
<UnstyledButton
p="xs"
onClick={handleClick}
className={classes.historyButton}
>
<Text size="sm">{date}</Text>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors
.slice(0, MAX_VISIBLE_AVATARS)
.map((contributor) => (
<Tooltip
key={contributor.id}
label={contributor.name}
withArrow
>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))}
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors
.slice(MAX_VISIBLE_AVATARS)
.map((c) => (
<div key={c.id}>{c.name}</div>
))}
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name}
</Text>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name}
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</UnstyledButton>
{!compareMode && (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
className={classes.itemMenu}
aria-label={t("Version actions for {{date}}", { date })}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
disabled={!canCompare}
onClick={() => onStartCompare(historyItem.id)}
>
{t("Compare")}
</Menu.Item>
{onRestore && (
<Menu.Item onClick={() => onRestore(historyItem.id, index)}>
{t("Restore")}
</Menu.Item>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</UnstyledButton>
</Menu.Dropdown>
</Menu>
)}
</div>
);
});
@@ -6,8 +6,12 @@ import HistoryItem from "@/features/page-history/components/history-item";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair";
import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
@@ -32,6 +36,9 @@ function HistoryList({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const [compareMode, setCompareMode] = useAtom(compareModeAtom);
const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom);
const setComparePair = useSetAtom(comparePairAtom);
const {
data: pageHistoryData,
@@ -79,10 +86,58 @@ function HistoryList({ pageId }: Props) {
const handleSelect = useCallback(
(id: string, index: number) => {
setComparePair(null);
setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
[historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair],
);
const handleToggleCompare = useCallback(
(id: string) => {
setCompareSelection((prev) => {
if (prev.includes(id)) return prev.filter((item) => item !== id);
if (prev.length >= 2) return prev;
return [...prev, id];
});
},
[setCompareSelection],
);
const handleStartCompare = useCallback(
(id: string) => {
setComparePair(null);
setCompareMode(true);
setCompareSelection([id]);
},
[setComparePair, setCompareMode, setCompareSelection],
);
const handleCancelCompare = useCallback(() => {
setCompareMode(false);
setCompareSelection([]);
}, [setCompareMode, setCompareSelection]);
const handleConfirmCompare = useCallback(() => {
const pair = resolveComparePair(historyItems, compareSelection);
if (!pair) return;
setComparePair(pair);
setCompareMode(false);
setCompareSelection([]);
}, [
historyItems,
compareSelection,
setComparePair,
setCompareMode,
setCompareSelection,
]);
const handleRestoreItem = useCallback(
(id: string, index: number) => {
handleSelect(id, index);
confirmRestore(id);
},
[handleSelect, confirmRestore],
);
useEffect(() => {
@@ -138,6 +193,16 @@ function HistoryList({ pageId }: Props) {
onHover={handleHover}
onHoverEnd={clearPrefetchTimeout}
isActive={historyItem.id === activeHistoryId}
compareMode={compareMode}
isChecked={compareSelection.includes(historyItem.id)}
isCheckboxDisabled={
!compareSelection.includes(historyItem.id) &&
compareSelection.length >= 2
}
canCompare={historyItems.length >= 2}
onToggleCompare={handleToggleCompare}
onStartCompare={handleStartCompare}
onRestore={canRestore ? handleRestoreItem : undefined}
/>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
@@ -148,22 +213,44 @@ function HistoryList({ pageId }: Props) {
)}
</ScrollArea>
{canRestore && (
{compareMode ? (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
onClick={handleCancelCompare}
>
{t("Cancel")}
</Button>
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
<Button
size="compact-md"
disabled={compareSelection.length !== 2}
onClick={handleConfirmCompare}
>
{t("Compare")}
</Button>
</Group>
</>
) : (
canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
<Button size="compact-md" onClick={() => confirmRestore()}>
{t("Restore")}
</Button>
</Group>
</>
)
)}
</div>
);
@@ -1,5 +1,6 @@
import {
ActionIcon,
CloseButton,
Group,
Paper,
ScrollArea,
@@ -12,17 +13,20 @@ import { useAtom, useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
comparePairAtom,
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view";
import { useRef } from "react";
import { useMemo, useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useDiffNavigation,
useHistoryReset,
} from "@/features/page-history/hooks";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { formattedDate } from "@/lib/time";
interface Props {
pageId: string;
@@ -36,6 +40,28 @@ export default function HistoryModalBody({ pageId }: Props) {
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
const [comparePair, setComparePair] = useAtom(comparePairAtom);
const { data: pageHistoryData } = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const compareLabel = useMemo(() => {
if (!comparePair) return null;
const newerItem = historyItems.find(
(item) => item.id === comparePair.newerId,
);
const olderItem = historyItems.find(
(item) => item.id === comparePair.olderId,
);
if (!newerItem || !olderItem) return null;
return t("Comparing {{newer}} and {{older}}", {
newer: formattedDate(new Date(newerItem.createdAt)),
older: formattedDate(new Date(olderItem.createdAt)),
});
}, [comparePair, historyItems, t]);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
@@ -50,6 +76,25 @@ export default function HistoryModalBody({ pageId }: Props) {
</nav>
<div style={{ position: "relative", flex: 1 }}>
{comparePair && (
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={4}
className={classes.compareBanner}
>
<Text size="sm" fw={500} lineClamp={1}>
{compareLabel ?? t("Compare versions")}
</Text>
<CloseButton
size="sm"
aria-label={t("Exit compare")}
onClick={() => setComparePair(null)}
/>
</Group>
)}
<ScrollArea
h={650}
w="100%"
@@ -57,11 +102,18 @@ export default function HistoryModalBody({ pageId }: Props) {
viewportRef={scrollViewportRef}
>
<div className={classes.sidebarRightSection}>
{activeHistoryId && <HistoryView />}
{comparePair ? (
<HistoryView
historyId={comparePair.newerId}
prevHistoryId={comparePair.olderId}
/>
) : (
activeHistoryId && <HistoryView />
)}
</div>
</ScrollArea>
{activeHistoryId && activeHistoryPrevId && (
{(comparePair || (activeHistoryId && activeHistoryPrevId)) && (
<Paper
shadow="md"
radius="xl"
@@ -166,7 +166,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button>
<Button onClick={() => confirmRestore()}>{t("Restore")}</Button>
</Group>
)}
@@ -7,21 +7,29 @@ import {
activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms";
function HistoryView() {
interface Props {
historyId?: string;
prevHistoryId?: string;
}
function HistoryView({ historyId, prevHistoryId }: Props) {
const { t } = useTranslation();
const historyId = useAtomValue(activeHistoryIdAtom);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
const activeId = useAtomValue(activeHistoryIdAtom);
const activePrevId = useAtomValue(activeHistoryPrevIdAtom);
const resolvedId = historyId ?? activeId;
const resolvedPrevId = prevHistoryId ?? activePrevId;
const {
data,
isLoading: isLoadingCurrent,
isError: isErrorCurrent,
} = usePageHistoryQuery(historyId);
} = usePageHistoryQuery(resolvedId);
const {
data: prevData,
isLoading: isLoadingPrev,
isError: isErrorPrev,
} = usePageHistoryQuery(prevHistoryId);
} = usePageHistoryQuery(resolvedPrevId);
if (isLoadingCurrent || isLoadingPrev) {
return <></>;
@@ -3,22 +3,45 @@ import { useEffect } from "react";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
diffCountsAtom,
} from "@/features/page-history/atoms/history-atoms";
/**
* Resets history state when pageId changes.
* Clears active selection and diff counts.
* Clears active selection, diff counts, and compare state.
* Compare state also resets on unmount so reopening the modal starts clean.
*/
export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
const [, setCompareMode] = useAtom(compareModeAtom);
const [, setCompareSelection] = useAtom(compareSelectionAtom);
const [, setComparePair] = useAtom(comparePairAtom);
useEffect(() => {
const resetCompare = () => {
setCompareMode(false);
setCompareSelection([]);
setComparePair(null);
};
setActiveHistoryId("");
setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null);
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
resetCompare();
return resetCompare;
}, [
pageId,
setActiveHistoryId,
setActiveHistoryPrevId,
setDiffCounts,
setCompareMode,
setCompareSelection,
setComparePair,
]);
}
@@ -1,4 +1,4 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
@@ -9,7 +9,8 @@ import {
activeHistoryIdAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import { fetchPageHistory } from "@/features/page-history/queries/page-history-query";
import { IPageHistory } from "@/features/page-history/types/page.types";
import {
pageEditorAtom,
titleEditorAtom,
@@ -25,8 +26,6 @@ export function useHistoryRestore() {
const { t } = useTranslation();
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const mainEditor = useAtomValue(pageEditorAtom);
const mainEditorTitle = useAtomValue(titleEditorAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
@@ -40,47 +39,66 @@ export function useHistoryRestore() {
SpaceCaslSubject.Page,
);
const handleRestore = useCallback(() => {
if (!activeHistoryData) return;
if (
!mainEditor ||
mainEditor.isDestroyed ||
!mainEditorTitle ||
mainEditorTitle.isDestroyed
) {
return;
}
const handleRestore = useCallback(
async (historyId: string) => {
let historyData: IPageHistory;
try {
historyData = await fetchPageHistory(historyId);
} catch {
notifications.show({
message: t("Error fetching page data."),
color: "red",
});
return;
}
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
if (
!mainEditor ||
mainEditor.isDestroyed ||
!mainEditorTitle ||
mainEditorTitle.isDestroyed
) {
return;
}
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
mainEditorTitle
.chain()
.clearContent()
.setContent(historyData.title, { emitUpdate: true })
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
mainEditor
.chain()
.clearContent()
.setContent(historyData.content)
.run();
const confirmRestore = useCallback(() => {
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore,
});
}, [t, handleRestore]);
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
},
[mainEditor, mainEditorTitle, setHistoryModalOpen, t],
);
const confirmRestore = useCallback(
(historyId?: string) => {
const targetId = historyId ?? activeHistoryId;
if (!targetId) return;
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: () => handleRestore(targetId),
});
},
[t, handleRestore, activeHistoryId],
);
return { canRestore, confirmRestore };
}
@@ -23,6 +23,14 @@ export function prefetchPageHistory(historyId: string) {
});
}
export function fetchPageHistory(historyId: string): Promise<IPageHistory> {
return queryClient.fetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery(
pageId: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { resolveComparePair } from "./resolve-compare-pair";
// list is newest-first, matching usePageHistoryListQuery order
const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }];
describe("resolveComparePair", () => {
it("orders newer before older regardless of selection order", () => {
expect(resolveComparePair(items, ["v1", "v3"])).toEqual({
newerId: "v3",
olderId: "v1",
});
expect(resolveComparePair(items, ["v3", "v1"])).toEqual({
newerId: "v3",
olderId: "v1",
});
});
it("returns null unless exactly two versions are selected", () => {
expect(resolveComparePair(items, [])).toBeNull();
expect(resolveComparePair(items, ["v1"])).toBeNull();
expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull();
});
it("returns null when a selected id is not in the list", () => {
expect(resolveComparePair(items, ["v1", "missing"])).toBeNull();
});
it("returns null when the same id is selected twice", () => {
expect(resolveComparePair(items, ["v2", "v2"])).toBeNull();
});
});
@@ -0,0 +1,18 @@
import { ComparePair } from "@/features/page-history/atoms/history-atoms";
/**
* Resolves which of the two selected versions is newer using their position
* in the history list (list is newest-first: lower index = newer).
*/
export function resolveComparePair(
historyItems: { id: string }[],
selection: string[],
): ComparePair | null {
if (selection.length !== 2) return null;
const indexA = historyItems.findIndex((item) => item.id === selection[0]);
const indexB = historyItems.findIndex((item) => item.id === selection[1]);
if (indexA === -1 || indexB === -1 || indexA === indexB) return null;
return indexA < indexB
? { newerId: selection[0], olderId: selection[1] }
: { newerId: selection[1], olderId: selection[0] };
}
@@ -11,6 +11,7 @@ import {
IconList,
IconMarkdown,
IconMessage,
IconPaperclip,
IconPrinter,
IconStar,
IconStarFilled,
@@ -42,6 +43,7 @@ import {
import { formattedDate } from "@/lib/time.ts";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
import {
@@ -157,6 +159,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
verificationOpened,
{ open: openVerificationModal, close: closeVerificationModal },
] = useDisclosure(false);
const [
attachmentsOpened,
{ open: openAttachmentsModal, close: closeAttachmentsModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page", page?.spaceId);
@@ -293,6 +299,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Item>
)}
{!page?.isBase && (
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={openAttachmentsModal}
>
{t("Attachments")}
</Menu.Item>
)}
{!readOnly && !page?.isBase && (
<PageVerificationMenuItem
pageId={page?.id}
@@ -395,6 +410,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
opened={verificationOpened}
onClose={closeVerificationModal}
/>
<PageAttachmentsModal
pageId={page.id}
open={attachmentsOpened}
onClose={closeAttachmentsModal}
/>
</>
);
}
+1
View File
@@ -27,6 +27,7 @@ const APP_ROUTE = {
SPACES: "/settings/spaces",
BILLING: "/settings/billing",
SECURITY: "/settings/security",
INTEGRATIONS: "/settings/integrations",
},
},
};
@@ -1,13 +1,10 @@
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { getAppName } from "@/lib/config";
import { Helmet } from "react-helmet-async";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function ForgotPassword() {
return (
<>
<Helmet>
<title>Forgot Password - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Forgot Password" />
<ForgotPasswordForm />
</>
);
+2 -5
View File
@@ -1,16 +1,13 @@
import { Helmet } from "react-helmet-async";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
import {getAppName} from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function InviteSignup() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("Invitation Signup")} - {getAppName()}</title>
</Helmet>
<DocumentTitle title={t("Invitation Signup")} />
<InviteSignUpForm />
</>
);
+2 -7
View File
@@ -1,18 +1,13 @@
import { LoginForm } from "@/features/auth/components/login-form";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function LoginPage() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Login")} />
<LoginForm />
</>
);
+3 -12
View File
@@ -1,11 +1,10 @@
import { Helmet } from "react-helmet-async";
import { PasswordResetForm } from "@/features/auth/components/password-reset-form";
import { Link, useSearchParams } from "react-router-dom";
import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query";
import { Button, Container, Group, Text } from "@mantine/core";
import APP_ROUTE from "@/lib/app-route";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function PasswordReset() {
const { t } = useTranslation();
@@ -23,11 +22,7 @@ export default function PasswordReset() {
if (isError || !resetToken) {
return (
<>
<Helmet>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Password Reset")} />
<Container my={40}>
<Text size="lg" ta="center">
{t("Invalid or expired password reset link")}
@@ -49,11 +44,7 @@ export default function PasswordReset() {
return (
<>
<Helmet>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Password Reset")} />
<PasswordResetForm resetToken={resetToken} />
</>
);
@@ -1,11 +1,10 @@
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function SetupWorkspace() {
const { t } = useTranslation();
@@ -35,11 +34,7 @@ export default function SetupWorkspace() {
) {
return (
<>
<Helmet>
<title>
{t("Setup Workspace")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Setup Workspace")} />
<SetupWorkspaceForm />
</>
);
+2 -7
View File
@@ -2,20 +2,15 @@ import { Container, Space } from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs";
import HomeAiPrompt from "@/features/home/components/home-ai-prompt";
import SpaceCarousel from "@/features/space/components/space-carousel.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Home() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Home")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Home")} />
<Container size={"900"} pt="xl">
<HomeAiPrompt />
+2 -7
View File
@@ -17,9 +17,7 @@ import {
} from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { useDebouncedValue } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import { useLabelPagesQuery } from "@/features/label/queries/label-query.ts";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
@@ -29,6 +27,7 @@ import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu.tsx";
import { EmptyState } from "@/components/ui/empty-state";
import classes from "@/features/label/label.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function LabelPage() {
const { t } = useTranslation();
@@ -82,11 +81,7 @@ export default function LabelPage() {
return (
<>
<Helmet>
<title>
{labelName} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={labelName} />
<Container size={820} py="xl">
<Stack gap="lg">
+9 -7
View File
@@ -3,7 +3,6 @@ import { usePageQuery } from "@/features/page/queries/page-query";
import { FullEditor } from "@/features/editor/full-editor";
import { TitleEditor } from "@/features/editor/title-editor";
import HistoryModal from "@/features/page-history/components/history-modal";
import { Helmet } from "react-helmet-async";
import PageHeader from "@/features/page/components/header/page-header.tsx";
import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
@@ -18,6 +17,7 @@ import { BaseView } from "@/ee/base/components/base-view";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { getPageTitle } from "@/features/page/page.utils";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageHeader = React.memo(PageHeader);
@@ -110,9 +110,10 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
paddingTop: "calc(var(--page-header-height) + 6px)",
}}
>
<Helmet>
<title>{`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}</title>
</Helmet>
<DocumentTitle
title={`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}
withAppName={false}
/>
<MemoizedPageHeader readOnly={!canEdit} />
<div
style={{
@@ -159,9 +160,10 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
return (
page && (
<div>
<Helmet>
<title>{`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}</title>
</Helmet>
<DocumentTitle
title={`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}
withAppName={false}
/>
<MemoizedPageHeader readOnly={!canEdit} />
@@ -5,21 +5,16 @@ import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref";
import FixedToolbarPref from "@/features/user/components/fixed-toolbar-pref";
import NotificationPref from "@/features/user/components/notification-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AccountPreferences() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Preferences")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Preferences")} />
<SettingsTitle title={t("Preferences")} />
<AccountTheme />
@@ -4,22 +4,17 @@ import ChangePassword from "@/features/user/components/change-password";
import { Divider } from "@mantine/core";
import AccountAvatar from "@/features/user/components/account-avatar";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
import SessionList from "@/features/session/components/session-list";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AccountSettings() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("My Profile")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("My Profile")} />
<SettingsTitle title={t("My Profile")} />
<AccountAvatar />
@@ -1,20 +1,15 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import GroupMembersList from "@/features/group/components/group-members";
import GroupDetails from "@/features/group/components/group-details";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function GroupInfo() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Manage Group")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Manage Group")} />
<SettingsTitle title={t("Manage Group")} />
<GroupDetails />
<GroupMembersList />
@@ -3,9 +3,8 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Group } from "@mantine/core";
import CreateGroupModal from "@/features/group/components/create-group-modal";
import useUserRole from "@/hooks/use-user-role.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Groups() {
const { t } = useTranslation();
@@ -13,9 +12,7 @@ export default function Groups() {
return (
<>
<Helmet>
<title>{t("Groups")} - {getAppName()}</title>
</Helmet>
<DocumentTitle title={t("Groups")} />
<SettingsTitle title={t("Groups")} />
<Group my="md" justify="flex-end">
@@ -1,22 +1,17 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import ShareList from "@/features/share/components/share-list.tsx";
import { Alert, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import React from "react";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Shares() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Public sharing")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Public sharing")} />
<SettingsTitle title={t("Public sharing")} />
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>

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