mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 05:07:53 +00:00
feat: add markdown rendering for task descriptions and comments (closes #63)
This commit is contained in:
parent
931d437b67
commit
9c2f193658
5 changed files with 1287 additions and 9 deletions
1148
pnpm-lock.yaml
generated
1148
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -39,7 +39,9 @@
|
|||
"nanoid": "^5.0.9",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.0"
|
||||
|
|
|
|||
124
web/src/components/shared/MarkdownText.tsx
Normal file
124
web/src/components/shared/MarkdownText.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* MarkdownText - Renders markdown content with sanitization
|
||||
*
|
||||
* Converts markdown text to formatted HTML while maintaining XSS protection.
|
||||
* Uses react-markdown for parsing (safe by default).
|
||||
*/
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { Components } from 'react-markdown';
|
||||
|
||||
interface MarkdownTextProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom components for react-markdown to apply Tailwind styling
|
||||
*/
|
||||
const components: Components = {
|
||||
// Headings
|
||||
h1: ({ children }) => <h1 className="text-2xl font-bold mt-4 mb-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-xl font-bold mt-3 mb-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-lg font-semibold mt-3 mb-1">{children}</h3>,
|
||||
h4: ({ children }) => <h4 className="text-base font-semibold mt-2 mb-1">{children}</h4>,
|
||||
h5: ({ children }) => <h5 className="text-sm font-semibold mt-2 mb-1">{children}</h5>,
|
||||
h6: ({ children }) => <h6 className="text-xs font-semibold mt-2 mb-1">{children}</h6>,
|
||||
|
||||
// Paragraphs
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
|
||||
// Lists
|
||||
ul: ({ children }) => <ul className="list-disc list-inside mb-2 space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 space-y-1">{children}</ol>,
|
||||
li: ({ children }) => <li className="ml-4">{children}</li>,
|
||||
|
||||
// Code
|
||||
code: ({ children, className }) => {
|
||||
// Check if this is inline code by looking at parent node
|
||||
// Inline code won't have a language class
|
||||
const isInline = !className;
|
||||
return isInline ? (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">{children}</code>
|
||||
) : (
|
||||
<code
|
||||
className={`block bg-muted p-3 rounded-md text-sm font-mono overflow-x-auto mb-2 ${className || ''}`}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => <pre className="mb-2">{children}</pre>,
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-muted-foreground/30 pl-4 italic my-2 text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Links
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline hover:text-primary/80"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: () => <hr className="border-border my-4" />,
|
||||
|
||||
// Tables
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto mb-2">
|
||||
<table className="border-collapse border border-border min-w-full">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => <tr className="border-b border-border">{children}</tr>,
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border px-3 py-2 text-left font-semibold">{children}</th>
|
||||
),
|
||||
td: ({ children }) => <td className="border border-border px-3 py-2">{children}</td>,
|
||||
|
||||
// Emphasis
|
||||
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
};
|
||||
|
||||
/**
|
||||
* MarkdownText component
|
||||
*
|
||||
* Renders markdown content with GFM (GitHub Flavored Markdown) support.
|
||||
* Automatically sanitizes HTML to prevent XSS attacks.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MarkdownText>{task.description}</MarkdownText>
|
||||
* ```
|
||||
*/
|
||||
export function MarkdownText({ children, className = '' }: MarkdownTextProps) {
|
||||
if (!children) return null;
|
||||
|
||||
return (
|
||||
<div className={`prose prose-sm max-w-none ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={components}
|
||||
// Use urlTransform to sanitize URLs - block javascript: and other dangerous protocols
|
||||
urlTransform={(url) => {
|
||||
if (url.match(/^\s*javascript:/i)) return '#';
|
||||
return url;
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,9 +14,9 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { MarkdownText } from '@/components/shared/MarkdownText';
|
||||
import { useAddComment, useEditComment, useDeleteComment } from '@/hooks/useTasks';
|
||||
import type { Task, Comment } from '@veritas-kanban/shared';
|
||||
import { sanitizeText } from '@/lib/sanitize';
|
||||
|
||||
interface CommentsSectionProps {
|
||||
task: Task;
|
||||
|
|
@ -144,9 +144,9 @@ function CommentItem({ comment, taskId }: { comment: Comment; taskId: string })
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap break-words">
|
||||
{sanitizeText(comment.text)}
|
||||
</p>
|
||||
<div className="text-sm text-foreground break-words">
|
||||
<MarkdownText>{comment.text}</MarkdownText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -246,7 +246,7 @@ export function CommentsSection({ task }: CommentsSectionProps) {
|
|||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a comment... (Cmd/Ctrl+Enter to submit)"
|
||||
placeholder="Add a comment... (supports Markdown, Cmd/Ctrl+Enter to submit)"
|
||||
className="text-sm min-h-[80px] resize-none"
|
||||
disabled={isAdding}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ import { TimeTrackingSection } from '../TimeTrackingSection';
|
|||
import { CommentsSection } from '../CommentsSection';
|
||||
import { BlockedReasonSection } from '../BlockedReasonSection';
|
||||
import { LessonsLearnedSection } from '../LessonsLearnedSection';
|
||||
import { MarkdownText } from '@/components/shared/MarkdownText';
|
||||
import { useDeleteTask, useArchiveTask } from '@/hooks/useTasks';
|
||||
import { useFeatureSettings } from '@/hooks/useFeatureSettings';
|
||||
import { Trash2, Archive, Calendar, Clock, RotateCcw } from 'lucide-react';
|
||||
import type { Task, BlockedReason } from '@veritas-kanban/shared';
|
||||
import { sanitizeText } from '@/lib/sanitize';
|
||||
|
||||
interface TaskDetailsTabProps {
|
||||
task: Task;
|
||||
|
|
@ -72,14 +72,18 @@ export function TaskDetailsTab({
|
|||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Description</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm whitespace-pre-wrap text-foreground/80 bg-muted/30 rounded-md p-3 min-h-[60px]">
|
||||
{sanitizeText(task.description || '') || 'No description'}
|
||||
<div className="text-sm text-foreground/80 bg-muted/30 rounded-md p-3 min-h-[60px]">
|
||||
{task.description ? (
|
||||
<MarkdownText>{task.description}</MarkdownText>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">No description</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={task.description}
|
||||
onChange={(e) => onUpdate('description', e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
placeholder="Add a description... (supports Markdown)"
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue