feat: support markdown

This commit is contained in:
Danish Arora 2025-09-15 15:14:43 +05:30
parent af59b9000a
commit a82bbe1243
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
7 changed files with 1639 additions and 27 deletions

1478
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -65,9 +65,12 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
@ -79,7 +82,7 @@
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",

View File

@ -11,7 +11,7 @@ import {
import { Button } from '@/components/ui/button';
import { BookmarkButton } from '@/components/ui/bookmark-button';
import { AuthorDisplay } from '@/components/ui/author-display';
import { LinkRenderer } from '@/components/ui/link-renderer';
import { MarkdownRenderer } from '@/components/ui/markdown-renderer';
import { usePending, usePendingVote } from '@/hooks/usePending';
import {
Tooltip,
@ -146,9 +146,9 @@ const CommentCard: React.FC<CommentCardProps> = ({
</div>
</div>
<p className="text-sm break-words mb-2">
<LinkRenderer text={comment.content} />
</p>
<div className="text-sm break-words mb-2 prose prose-invert max-w-none">
<MarkdownRenderer content={comment.content} />
</div>
<div className="flex items-center gap-2">
{canModerate && !comment.moderated && (

View File

@ -10,7 +10,8 @@ import {
} from '@/hooks';
import { Button } from '@/components/ui/button';
//
import ResizableTextarea from '@/components/ui/resizable-textarea';
// import ResizableTextarea from '@/components/ui/resizable-textarea';
import { MarkdownInput } from '@/components/ui/markdown-input';
import {
ArrowLeft,
ArrowUp,
@ -25,7 +26,7 @@ import { formatDistanceToNow } from 'date-fns';
import { RelevanceIndicator } from './ui/relevance-indicator';
import { AuthorDisplay } from './ui/author-display';
import { BookmarkButton } from './ui/bookmark-button';
import { LinkRenderer } from './ui/link-renderer';
import { MarkdownRenderer } from './ui/markdown-renderer';
import CommentCard from './CommentCard';
import { usePending, usePendingVote } from '@/hooks/usePending';
import { ShareButton } from './ui/ShareButton';
@ -263,9 +264,9 @@ const PostDetail = () => {
title={post.title}
/>
</div>
<p className="text-sm whitespace-pre-wrap break-words">
<LinkRenderer text={post.content} />
</p>
<div className="text-sm break-words prose prose-invert max-w-none">
<MarkdownRenderer content={post.content} />
</div>
</div>
</div>
</div>
@ -279,20 +280,15 @@ const PostDetail = () => {
<MessageCircle className="w-4 h-4" />
Add a comment
</h2>
<ResizableTextarea
<MarkdownInput
placeholder="What are your thoughts?"
value={newComment}
onChange={e => setNewComment(e.target.value)}
className="bg-cyber-muted/50 border-cyber-muted"
onChange={setNewComment}
disabled={isCreatingComment}
minHeight={100}
initialHeight={140}
maxHeight={600}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-1 mb-2">
<span>Press Enter for newline Ctrl+Enter or Shift+Enter to send</span>
<span />
</div>
<div className="flex justify-end">
<Button
type="submit"

View File

@ -0,0 +1,75 @@
import React from 'react';
import ResizableTextarea from '@/components/ui/resizable-textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { MarkdownRenderer } from './markdown-renderer';
interface MarkdownInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
minHeight?: number;
initialHeight?: number;
maxHeight?: number;
}
/**
* Textarea with Markdown preview tabs.
*/
export const MarkdownInput: React.FC<MarkdownInputProps> = ({
value,
onChange,
placeholder,
disabled,
className,
minHeight,
initialHeight,
maxHeight,
}) => {
const [tab, setTab] = React.useState<'write' | 'preview'>('write');
return (
<div className={className}>
<Tabs value={tab} onValueChange={v => setTab(v as 'write' | 'preview')}>
<TabsList className="mb-2">
<TabsTrigger value="write">Write</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
</TabsList>
<TabsContent value="write">
<ResizableTextarea
placeholder={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
className="bg-cyber-muted/50 border-cyber-muted"
disabled={disabled}
minHeight={minHeight}
initialHeight={initialHeight}
maxHeight={maxHeight}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-1 mb-2">
<span>Markdown supported Ctrl+Enter or Shift+Enter to send</span>
<button
type="button"
className="underline hover:opacity-80"
onClick={() => setTab('preview')}
>
Preview
</button>
</div>
</TabsContent>
<TabsContent value="preview">
<div className="p-3 border rounded-sm bg-card">
<MarkdownRenderer content={value} className="prose prose-invert max-w-none" />
</div>
</TabsContent>
</Tabs>
</div>
);
};
export default MarkdownInput;

View File

@ -0,0 +1,77 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
interface MarkdownRendererProps {
content: string;
className?: string;
}
/**
* Renders sanitized Markdown with GFM support.
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className }) => {
// Extend sanitize schema to allow common markdown elements (headings, lists, code, tables, etc.)
const schema: any = {
...defaultSchema,
tagNames: [
...(defaultSchema.tagNames || []),
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'p',
'ul',
'ol',
'li',
'strong',
'em',
'del',
'blockquote',
'hr',
'code',
'pre',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'a',
'img',
],
attributes: {
...defaultSchema.attributes,
a: [
...(defaultSchema.attributes?.a || []),
['href'],
['target'],
['rel'],
],
img: [
...(defaultSchema.attributes?.img || []),
['src'],
['alt'],
['title'],
],
code: [
...(defaultSchema.attributes?.code || []),
['className'],
],
},
};
return (
<div className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[[rehypeSanitize, schema]]}>
{content || ''}
</ReactMarkdown>
</div>
);
};
export default MarkdownRenderer;

View File

@ -1,5 +1,6 @@
import type { Config } from 'tailwindcss';
import tailwindcssAnimate from 'tailwindcss-animate';
import typography from '@tailwindcss/typography';
export default {
darkMode: ['class'],
@ -117,5 +118,5 @@ export default {
},
},
},
plugins: [tailwindcssAnimate],
plugins: [tailwindcssAnimate, typography],
} satisfies Config;