feat(issues): render issue mentions as rich cards with status icon
- Fix mention markdown serialization: use renderMarkdown (tiptap/markdown 3.x API) instead of addStorage.markdown.serialize which was silently ignored - Add IssueMentionCard component showing status icon + identifier + title - Update Markdown renderer to use card for mention://issue/ links
This commit is contained in:
parent
e0e52bca64
commit
e16f1579a9
4 changed files with 49 additions and 60 deletions
|
|
@ -14,7 +14,6 @@ import Typography from "@tiptap/extension-typography";
|
|||
import Mention from "@tiptap/extension-mention";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import type { JSONContent, MarkdownParseHelpers, MarkdownToken } from "@tiptap/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import "./rich-text-editor.css";
|
||||
|
|
@ -83,9 +82,6 @@ const LinkExtension = Link.configure({
|
|||
},
|
||||
});
|
||||
|
||||
const MENTION_LINK_RE =
|
||||
/^\[(@?[^\]]*)\]\(mention:\/\/(member|agent|issue)\/([^)]+)\)/;
|
||||
|
||||
const MentionExtension = Mention.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
suggestion: createMentionSuggestion(),
|
||||
|
|
@ -109,59 +105,22 @@ const MentionExtension = Mention.configure({
|
|||
...this.parent?.(),
|
||||
type: {
|
||||
default: "member",
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-mention-type") ?? "member",
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member",
|
||||
},
|
||||
description: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-mention-description"),
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-description"),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// -- Markdown serialization: [@Label](mention://type/id) --
|
||||
renderMarkdown(node: JSONContent) {
|
||||
const type = (node.attrs?.type as string) ?? "member";
|
||||
const label = (node.attrs?.label as string) ?? node.attrs?.id;
|
||||
// @tiptap/markdown 3.x uses renderMarkdown as a top-level extension field
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
renderMarkdown(node: any) {
|
||||
const type = node.attrs?.type ?? "member";
|
||||
const label = node.attrs?.label ?? node.attrs?.id;
|
||||
const display = type === "issue" ? label : `@${label}`;
|
||||
return `[${display}](mention://${type}/${node.attrs?.id})`;
|
||||
},
|
||||
|
||||
// -- Markdown parsing: turn the link back into a mention node --
|
||||
parseMarkdown(token: MarkdownToken, h: MarkdownParseHelpers) {
|
||||
return h.createNode("mention", {
|
||||
id: token.attributes?.id,
|
||||
label: token.attributes?.label,
|
||||
type: token.attributes?.type ?? "member",
|
||||
});
|
||||
},
|
||||
|
||||
markdownTokenizer: {
|
||||
name: "mention",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
// Find [@ or [ followed by ](mention://
|
||||
const idx = src.indexOf("](mention://");
|
||||
if (idx === -1) return -1;
|
||||
// Walk back to find the opening [
|
||||
const bracketIdx = src.lastIndexOf("[", idx);
|
||||
return bracketIdx === -1 ? -1 : bracketIdx;
|
||||
},
|
||||
tokenize(src: string) {
|
||||
const match = MENTION_LINK_RE.exec(src);
|
||||
if (!match) return undefined;
|
||||
const [raw, displayLabel = "", type, id] = match;
|
||||
const label =
|
||||
displayLabel.startsWith("@") ? displayLabel.slice(1) : displayLabel;
|
||||
return {
|
||||
type: "mention",
|
||||
raw,
|
||||
content: "",
|
||||
attributes: { id, label, type },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CodeBlock, InlineCode } from './CodeBlock'
|
||||
import { preprocessLinks } from './linkify'
|
||||
import { IssueMentionCard } from '@/features/issues/components/issue-mention-card'
|
||||
|
||||
/**
|
||||
* Render modes for markdown content:
|
||||
|
|
@ -71,17 +71,9 @@ function createComponents(
|
|||
// Mention links: mention://member/id, mention://agent/id, mention://issue/id
|
||||
if (href?.startsWith('mention://')) {
|
||||
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue)\/(.+)$/)
|
||||
if (mentionMatch?.[1] === 'issue') {
|
||||
const issueId = mentionMatch[2]
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="text-primary font-medium cursor-pointer hover:underline"
|
||||
style={{ background: 'color-mix(in srgb, var(--primary) 8%, transparent)', padding: '0 0.2em', borderRadius: 'calc(var(--radius) * 0.5)' }}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
if (mentionMatch?.[1] === 'issue' && mentionMatch[2]) {
|
||||
const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined
|
||||
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />
|
||||
}
|
||||
return (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export { IssuesPage } from "./issues-page";
|
|||
export { CommentCard } from "./comment-card";
|
||||
export { CommentInput } from "./comment-input";
|
||||
export { ReplyInput } from "./reply-input";
|
||||
export { IssueMentionCard } from "./issue-mention-card";
|
||||
|
|
|
|||
37
apps/web/features/issues/components/issue-mention-card.tsx
Normal file
37
apps/web/features/issues/components/issue-mention-card.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
|
||||
interface IssueMentionCardProps {
|
||||
issueId: string;
|
||||
/** Fallback text when issue is not in store (e.g. "MUL-7") */
|
||||
fallbackLabel?: string;
|
||||
}
|
||||
|
||||
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="text-primary font-medium cursor-pointer hover:underline"
|
||||
>
|
||||
{fallbackLabel ?? issueId.slice(0, 8)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer no-underline"
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
|
||||
<span className="text-foreground">{issue.title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue