mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 12:53:10 +00:00
feat: support markdown
This commit is contained in:
parent
af59b9000a
commit
a82bbe1243
1478
package-lock.json
generated
1478
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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"
|
||||
|
||||
75
src/components/ui/markdown-input.tsx
Normal file
75
src/components/ui/markdown-input.tsx
Normal 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;
|
||||
|
||||
|
||||
77
src/components/ui/markdown-renderer.tsx
Normal file
77
src/components/ui/markdown-renderer.tsx
Normal 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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user