miniperplx/components/markdown-render.tsx
2024-10-25 01:14:58 +05:30

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;