208 lines
7.8 KiB
TypeScript
208 lines
7.8 KiB
TypeScript
import React, { useState, useCallback, useMemo } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import Marked, { ReactRenderer } from 'marked-react';
|
|
import SyntaxHighlighter from 'react-syntax-highlighter';
|
|
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
|
import { Button } from '@/components/ui/button';
|
|
import { Check, Copy } from 'lucide-react';
|
|
import Image from 'next/image';
|
|
import Link from 'next/link';
|
|
import { fetchMetadata } from '@/app/actions';
|
|
|
|
interface MarkdownRendererProps {
|
|
content: string;
|
|
}
|
|
|
|
interface CitationLink {
|
|
text: string;
|
|
link: string;
|
|
}
|
|
|
|
interface LinkMetadata {
|
|
title: string;
|
|
description: string;
|
|
}
|
|
|
|
const isValidUrl = (str: string) => {
|
|
try {
|
|
new URL(str);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content }) => {
|
|
const [metadataCache, setMetadataCache] = useState<Record<string, LinkMetadata>>({});
|
|
|
|
const citationLinks = useMemo<CitationLink[]>(() => {
|
|
return Array.from(content.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)).map(([_, text, link]) => ({
|
|
text,
|
|
link,
|
|
}));
|
|
}, [content]);
|
|
|
|
const fetchMetadataWithCache = useCallback(async (url: string) => {
|
|
if (metadataCache[url]) {
|
|
return metadataCache[url];
|
|
}
|
|
|
|
const metadata = await fetchMetadata(url);
|
|
if (metadata) {
|
|
setMetadataCache(prev => ({ ...prev, [url]: metadata }));
|
|
}
|
|
return metadata;
|
|
}, [metadataCache]);
|
|
|
|
const CodeBlock = ({ language, children }: { language: string | undefined; children: string }) => {
|
|
const [isCopied, setIsCopied] = useState(false);
|
|
|
|
const handleCopy = async () => {
|
|
await navigator.clipboard.writeText(children);
|
|
setIsCopied(true);
|
|
setTimeout(() => setIsCopied(false), 2000);
|
|
};
|
|
|
|
return (
|
|
<div className="relative group">
|
|
<SyntaxHighlighter
|
|
language={language || 'text'}
|
|
style={oneDark}
|
|
showLineNumbers
|
|
wrapLines
|
|
customStyle={{
|
|
margin: 0,
|
|
borderRadius: '0.375rem',
|
|
fontSize: '0.875rem',
|
|
}}
|
|
>
|
|
{children}
|
|
</SyntaxHighlighter>
|
|
<Button
|
|
onClick={handleCopy}
|
|
className="absolute top-2 right-2 p-2 bg-neutral-700 dark:bg-neutral-600 bg-opacity-80 rounded-md opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
|
variant="ghost"
|
|
size="sm"
|
|
>
|
|
{isCopied ? <Check size={16} className="text-green-500" /> : <Copy size={16} className="text-neutral-200" />}
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const LinkPreview = ({ href }: { href: string }) => {
|
|
const [metadata, setMetadata] = useState<LinkMetadata | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
React.useEffect(() => {
|
|
setIsLoading(true);
|
|
fetchMetadataWithCache(href).then((data) => {
|
|
setMetadata(data);
|
|
setIsLoading(false);
|
|
});
|
|
}, [href]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center p-4">
|
|
<div className="animate-spin h-5 w-5 text-neutral-500 dark:text-neutral-400" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const domain = new URL(href).hostname;
|
|
|
|
return (
|
|
<div className="flex flex-col space-y-2 bg-white dark:bg-neutral-800 rounded-md shadow-md overflow-hidden">
|
|
<div className="flex items-center space-x-2 p-3 bg-neutral-100 dark:bg-neutral-700">
|
|
<Image
|
|
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=256`}
|
|
alt="Favicon"
|
|
width={20}
|
|
height={20}
|
|
className="rounded-sm"
|
|
/>
|
|
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300 truncate">{domain}</span>
|
|
</div>
|
|
<div className="px-3 pb-3">
|
|
<h3 className="text-base font-semibold text-neutral-800 dark:text-neutral-200 line-clamp-2">
|
|
{metadata?.title || "Untitled"}
|
|
</h3>
|
|
{metadata?.description && (
|
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1 line-clamp-2">
|
|
{metadata.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderHoverCard = (href: string, text: React.ReactNode, isCitation: boolean = false) => {
|
|
return (
|
|
<HoverCard>
|
|
<HoverCardTrigger asChild>
|
|
<Link
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={isCitation ? "cursor-help text-sm text-primary py-0.5 px-1.5 m-0 bg-neutral-200 dark:bg-neutral-700 rounded-full no-underline" : "text-teal-600 dark:text-teal-400 no-underline hover:underline"}
|
|
>
|
|
{text}
|
|
</Link>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
side="top"
|
|
align="start"
|
|
className="w-80 p-0 shadow-lg"
|
|
>
|
|
<LinkPreview href={href} />
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
);
|
|
};
|
|
|
|
const renderer: Partial<ReactRenderer> = {
|
|
paragraph(children) {
|
|
return <p className="my-4 text-neutral-800 dark:text-neutral-200">{children}</p>;
|
|
},
|
|
code(children, language) {
|
|
return <CodeBlock language={language}>{String(children)}</CodeBlock>;
|
|
},
|
|
link(href, text) {
|
|
const citationIndex = citationLinks.findIndex(link => link.link === href);
|
|
if (citationIndex !== -1) {
|
|
return (
|
|
<sup>
|
|
{renderHoverCard(href, citationIndex + 1, true)}
|
|
</sup>
|
|
);
|
|
}
|
|
return isValidUrl(href) ? renderHoverCard(href, text) : <a href={href} className="text-blue-600 dark:text-blue-400 hover:underline">{text}</a>;
|
|
},
|
|
heading(children, level) {
|
|
const HeadingTag = `h${level}` as keyof JSX.IntrinsicElements;
|
|
const className = `text-${4 - level}xl font-bold my-4 text-neutral-800 dark:text-neutral-100`;
|
|
return <HeadingTag className={className}>{children}</HeadingTag>;
|
|
},
|
|
list(children, ordered) {
|
|
const ListTag = ordered ? 'ol' : 'ul';
|
|
return <ListTag className="list-inside list-disc my-4 pl-4 text-neutral-800 dark:text-neutral-200">{children}</ListTag>;
|
|
},
|
|
listItem(children) {
|
|
return <li className="my-2 text-neutral-800 dark:text-neutral-200">{children}</li>;
|
|
},
|
|
blockquote(children) {
|
|
return <blockquote className="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic my-4 text-neutral-700 dark:text-neutral-300">{children}</blockquote>;
|
|
},
|
|
};
|
|
|
|
return (
|
|
<div className="markdown-body dark:text-neutral-200">
|
|
<Marked renderer={renderer}>{content}</Marked>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MarkdownRenderer; |