mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-06 23:03:07 +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-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",
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
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 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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user