From 16f7139e6fdb0c8d0fbe5df817e8317cde22c477 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:01:52 +0100 Subject: [PATCH] wip --- .../public/locales/en-US/translation.json | 3 + .../common/editor-paste-handler.tsx | 19 +- .../integration-link/badge-color.ts | 28 ++ .../integration-link-view.tsx | 97 ++++++- .../integration-mention-view.tsx | 27 +- .../integration-paste-menu.tsx | 68 +++-- .../src/features/editor/page-editor.tsx | 10 + .../components/integration-card.tsx | 104 ------- .../components/integration-row.tsx | 9 - .../components/integration-settings-modal.tsx | 34 --- .../integration/pages/integrations.tsx | 16 +- apps/server/src/core/core.module.ts | 4 + .../dto/integration-settings.schema.ts | 16 -- .../core/integration/integration.listener.ts | 2 +- .../providers/github/github-patterns.ts | 72 +++++ .../providers/github/github.module.ts | 21 ++ .../providers/github/github.provider.ts | 157 ++++++++++ .../providers/github/github.service.ts | 272 ++++++++++++++++++ .../providers/gitlab/gitlab-patterns.ts | 68 +++++ .../providers/gitlab/gitlab.module.ts | 21 ++ .../providers/gitlab/gitlab.provider.ts | 191 ++++++++++++ .../providers/gitlab/gitlab.service.ts | 258 +++++++++++++++++ .../core/integration/utils/relative-time.ts | 5 + .../integration-link-patterns.ts | 24 +- 24 files changed, 1303 insertions(+), 223 deletions(-) delete mode 100644 apps/client/src/features/integration/components/integration-card.tsx delete mode 100644 apps/client/src/features/integration/components/integration-settings-modal.tsx create mode 100644 apps/server/src/core/integration/providers/github/github-patterns.ts create mode 100644 apps/server/src/core/integration/providers/github/github.module.ts create mode 100644 apps/server/src/core/integration/providers/github/github.provider.ts create mode 100644 apps/server/src/core/integration/providers/github/github.service.ts create mode 100644 apps/server/src/core/integration/providers/gitlab/gitlab-patterns.ts create mode 100644 apps/server/src/core/integration/providers/gitlab/gitlab.module.ts create mode 100644 apps/server/src/core/integration/providers/gitlab/gitlab.provider.ts create mode 100644 apps/server/src/core/integration/providers/gitlab/gitlab.service.ts create mode 100644 apps/server/src/core/integration/utils/relative-time.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 489f1a27d..323cefcc7 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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", @@ -216,6 +218,7 @@ "To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.", "Toggle full page width": "Toggle full page width", "Unable to import pages. Please try again.": "Unable to import pages. Please try again.", + "Unassigned": "Unassigned", "untitled": "untitled", "Untitled": "Untitled", "Updated successfully": "Updated successfully", diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index 4df5365ce..658bad9d7 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -7,6 +7,8 @@ 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, @@ -24,6 +26,17 @@ const ATTACHMENT_NODE_TYPES = [ const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//; +// Only installed + enabled 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([ + "installed-integrations", + ]); + const integration = installed?.find((i) => i.type === provider); + return Boolean(integration?.isEnabled); +} + export const handlePaste = ( editor: Editor, event: ClipboardEvent, @@ -33,7 +46,11 @@ export const handlePaste = ( const clipboardData = event.clipboardData.getData("text/plain"); const integrationMatch = matchIntegrationLink(clipboardData.trim()); - if (integrationMatch && editor.state.selection.empty) { + if ( + integrationMatch && + editor.state.selection.empty && + isIntegrationInstalled(integrationMatch.provider) + ) { event.preventDefault(); const pastedUrl = clipboardData.trim(); editor diff --git a/apps/client/src/features/editor/components/integration-link/badge-color.ts b/apps/client/src/features/editor/components/integration-link/badge-color.ts index cc5ab1795..da8fe34fc 100644 --- a/apps/client/src/features/editor/components/integration-link/badge-color.ts +++ b/apps/client/src/features/editor/components/integration-link/badge-color.ts @@ -1,3 +1,31 @@ +// 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 = { + 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("#", ""); diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx index 940e19f70..36281284d 100644 --- a/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx +++ b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx @@ -17,7 +17,7 @@ import { getIntegrationIcon } from "@/features/integration/components/integratio import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service"; import { timeAgo } from "@/lib/time"; import { useUnfurl } from "./use-unfurl"; -import { toBadgeColor } from "./badge-color"; +import { badgeTextColor, toBadgeColor } from "./badge-color"; import classes from "./integration-link-view.module.css"; const SLACK_TEXT_CLAMP_LINES = 4; @@ -125,6 +125,96 @@ function SlackMessageCard({ ); } +function JiraIssueCard({ + url, + unfurlData, +}: { + url: string; + unfurlData: Record; +}) { + 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 ( + + + + {unfurlData.authorAvatarUrl ? ( + + ) : ( +
{getIntegrationIcon("jira", 28)}
+ )} + + + + + {unfurlData.title} + + {unfurlData.status && ( + + {unfurlData.status} + + )} + + + + {meta.issueTypeIconUrl && ( + + )} + + {infoLine} + + + + +
+ {getIntegrationIcon("jira", 18)} +
+
+
+
+ ); +} + function IntegrationLinkView(props: any) { const { node, updateAttributes, editor } = props; const { url, provider, unfurlData, status } = node.attrs; @@ -231,6 +321,10 @@ function IntegrationLinkView(props: any) { return ; } + if (provider === "jira" && unfurlData.metadata?.issueKey) { + return ; + } + return ( {unfurlData.status} diff --git a/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx index 695f0cae6..3c28912bb 100644 --- a/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx +++ b/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx @@ -4,7 +4,7 @@ import { memo } from "react"; import { useTranslation } from "react-i18next"; import { getIntegrationIcon } from "@/features/integration/components/integration-icons"; import { useUnfurl } from "./use-unfurl"; -import { toBadgeColor } from "./badge-color"; +import { badgeTextColor, toBadgeColor } from "./badge-color"; import classes from "./integration-link-view.module.css"; function shortUrl(url: string): string { @@ -40,6 +40,7 @@ function IntegrationMentionView(props: any) { size="xs" variant="light" color={toBadgeColor(data.statusColor)} + c={badgeTextColor(toBadgeColor(data.statusColor))} className={classes.mentionIcon} > {data.status} @@ -80,6 +81,7 @@ function IntegrationMentionView(props: any) { size="xs" variant="light" color="gray" + c={badgeTextColor("gray")} tt="none" className={classes.mentionIcon} > @@ -88,6 +90,29 @@ function IntegrationMentionView(props: any) { )} ); + } else if (meta.issueKey) { + // Jira: type icon leads, provider icon trails. + content = ( + <> + {meta.issueTypeIconUrl ? ( + + ) : ( + getIntegrationIcon(provider, 14) + )} + + {meta.issueKey} + + {data.title} + {statusBadge} + {meta.issueTypeIconUrl && getIntegrationIcon(provider, 14)} + + ); } else if (issueNumber) { content = ( <> diff --git a/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx index 47a2a8b5f..bcab38a86 100644 --- a/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx +++ b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx @@ -62,7 +62,7 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) { }, [menuState, dismiss]); const convert = useCallback( - (target: "preview" | "mention" | "url") => { + (target: "card" | "mention" | "url") => { const found = findTarget(); if (!found) { dismiss(); @@ -74,45 +74,37 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) { const to = pos + node.nodeSize; const isBlock = node.type.name === "integrationLink"; - if (target === "preview" && !isBlock) { - editor - .chain() - .focus(undefined, { scrollIntoView: false }) - .deleteRange({ from, to }) - .insertContentAt(from, { type: "integrationLink", attrs }) - .run(); - } else if (target === "mention" && isBlock) { - editor - .chain() - .focus(undefined, { scrollIntoView: false }) - .deleteRange({ from, to }) - .insertContentAt(from, { - type: "paragraph", - content: [ - { type: "integrationMention", attrs }, - { type: "text", text: " " }, - ], - }) - .run(); - } else if (target === "url") { + // 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; + 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 } }], }; - editor - .chain() - .focus(undefined, { scrollIntoView: false }) - .deleteRange({ from, to }) - .insertContentAt( - from, - isBlock ? { type: "paragraph", content: [linkText] } : linkText, - ) - .run(); - } else { - // already in the requested form - dismiss(); + content = isBlock + ? { type: "paragraph", content: [linkText] } + : linkText; } + + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .deleteRange({ from, to }) + .insertContentAt(from, content) + .run(); }, [editor, findTarget, dismiss], ); @@ -126,6 +118,9 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) { 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 ? ( {t("Paste as")} @@ -137,9 +132,9 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) { size="compact-sm" fullWidth justify="flex-start" - onClick={() => convert("preview")} + onClick={() => convert("card")} > - {t("Preview")} + {t("Card")} - - - - ) : ( - - )} - - ); -} diff --git a/apps/client/src/features/integration/components/integration-row.tsx b/apps/client/src/features/integration/components/integration-row.tsx index ef67a4852..a4b3f8901 100644 --- a/apps/client/src/features/integration/components/integration-row.tsx +++ b/apps/client/src/features/integration/components/integration-row.tsx @@ -11,7 +11,6 @@ type IntegrationRowProps = { installation?: Integration; onInstall: (type: string) => void; onUninstall: (integrationId: string) => void; - onConfigure: (integration: Integration) => void; onToggle: (integration: Integration, enabled: boolean) => void; }; @@ -20,7 +19,6 @@ export default function IntegrationRow({ installation, onInstall, onUninstall, - onConfigure, onToggle, }: IntegrationRowProps) { const { t } = useTranslation(); @@ -64,13 +62,6 @@ export default function IntegrationRow({ } size="sm" /> -