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

View File

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

View File

@ -10,7 +10,8 @@ import {
} from '@/hooks'; } from '@/hooks';
import { Button } from '@/components/ui/button'; 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 { import {
ArrowLeft, ArrowLeft,
ArrowUp, ArrowUp,
@ -25,7 +26,7 @@ import { formatDistanceToNow } from 'date-fns';
import { RelevanceIndicator } from './ui/relevance-indicator'; import { RelevanceIndicator } from './ui/relevance-indicator';
import { AuthorDisplay } from './ui/author-display'; import { AuthorDisplay } from './ui/author-display';
import { BookmarkButton } from './ui/bookmark-button'; import { BookmarkButton } from './ui/bookmark-button';
import { LinkRenderer } from './ui/link-renderer'; import { MarkdownRenderer } from './ui/markdown-renderer';
import CommentCard from './CommentCard'; import CommentCard from './CommentCard';
import { usePending, usePendingVote } from '@/hooks/usePending'; import { usePending, usePendingVote } from '@/hooks/usePending';
import { ShareButton } from './ui/ShareButton'; import { ShareButton } from './ui/ShareButton';
@ -263,9 +264,9 @@ const PostDetail = () => {
title={post.title} title={post.title}
/> />
</div> </div>
<p className="text-sm whitespace-pre-wrap break-words"> <div className="text-sm break-words prose prose-invert max-w-none">
<LinkRenderer text={post.content} /> <MarkdownRenderer content={post.content} />
</p> </div>
</div> </div>
</div> </div>
</div> </div>
@ -279,20 +280,15 @@ const PostDetail = () => {
<MessageCircle className="w-4 h-4" /> <MessageCircle className="w-4 h-4" />
Add a comment Add a comment
</h2> </h2>
<ResizableTextarea <MarkdownInput
placeholder="What are your thoughts?" placeholder="What are your thoughts?"
value={newComment} value={newComment}
onChange={e => setNewComment(e.target.value)} onChange={setNewComment}
className="bg-cyber-muted/50 border-cyber-muted"
disabled={isCreatingComment} disabled={isCreatingComment}
minHeight={100} minHeight={100}
initialHeight={140} initialHeight={140}
maxHeight={600} 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"> <div className="flex justify-end">
<Button <Button
type="submit" 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 type { Config } from 'tailwindcss';
import tailwindcssAnimate from 'tailwindcss-animate'; import tailwindcssAnimate from 'tailwindcss-animate';
import typography from '@tailwindcss/typography';
export default { export default {
darkMode: ['class'], darkMode: ['class'],
@ -117,5 +118,5 @@ export default {
}, },
}, },
}, },
plugins: [tailwindcssAnimate], plugins: [tailwindcssAnimate, typography],
} satisfies Config; } satisfies Config;