mission-control/components/MarkdownPreviewDialog.tsx

402 lines
12 KiB
TypeScript

'use client';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { X, Maximize2, Minimize2, Folder, Tag } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useEffect, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface MarkdownPreviewDialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
content: string;
folder?: string;
tags?: string[];
}
export function MarkdownPreviewDialog({
isOpen,
onClose,
title,
content,
folder,
tags,
}: MarkdownPreviewDialogProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (!isOpen) {
setIsFullscreen(false);
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
showCloseButton={false}
className={`
${isFullscreen
? 'inset-0 top-0 left-0 right-0 bottom-0 w-screen h-dvh max-w-none sm:max-w-none rounded-none border-0 translate-x-0 translate-y-0'
: 'w-[95vw] h-[90vh] max-w-none sm:max-w-none'
}
p-0 gap-0 overflow-hidden bg-slate-950 border-slate-800 flex flex-col
`}
>
{/* Header */}
<DialogHeader className="px-6 py-4 border-b border-slate-800 bg-slate-900/50 flex-shrink-0">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<DialogTitle className="text-xl font-semibold text-slate-100 truncate">
{title}
</DialogTitle>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => setIsFullscreen(!isFullscreen)}
className="h-9 w-9 text-slate-400 hover:text-slate-100 hover:bg-slate-800"
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? (
<Minimize2 className="w-5 h-5" />
) : (
<Maximize2 className="w-5 h-5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-9 w-9 text-slate-400 hover:text-slate-100 hover:bg-slate-800"
title="Close"
>
<X className="w-5 h-5" />
</Button>
</div>
</div>
{/* Metadata row: Folder and Tags */}
<div className="flex flex-wrap items-center gap-3 mt-3">
{folder && (
<div className="flex items-center gap-1.5 text-sm text-slate-400">
<Folder className="w-3.5 h-3.5 text-slate-500" />
<span className="bg-slate-800/70 px-2 py-0.5 rounded text-slate-300">
{folder}
</span>
</div>
)}
{tags && tags.length > 0 && (
<div className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 text-slate-500" />
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span
key={tag}
className="text-xs text-slate-400 bg-slate-800/50 px-2 py-0.5 rounded border border-slate-700/50"
>
#{tag}
</span>
))}
</div>
</div>
)}
</div>
</DialogHeader>
{/* Content */}
<div className="flex-1 overflow-auto bg-slate-950">
<div
className={`mx-auto w-full ${
isFullscreen ? 'max-w-none px-4 sm:px-8 py-6' : 'max-w-5xl px-4 sm:px-6 lg:px-8 py-6 sm:py-8'
}`}
>
<article className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<div className="code-block-wrapper">
<div className="code-block-header">
<span className="code-language">{match[1]}</span>
</div>
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
borderRadius: '0 0 8px 8px',
background: '#020617',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
pre({ children }) {
return <>{children}</>;
},
}}
>
{content}
</ReactMarkdown>
</article>
</div>
</div>
{/* Footer with line count */}
<div className="px-6 py-2 border-t border-slate-800 bg-slate-900/30 flex-shrink-0">
<div className="text-xs text-slate-500 text-right">
{content.split(/\r?\n/).length} lines
</div>
</div>
{/* Custom styles for markdown content */}
<style jsx global>{`
.markdown-body {
color: #e2e8f0;
line-height: 1.75;
font-size: 16px;
}
.markdown-body > *:first-child {
margin-top: 0;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
color: #f8fafc;
margin-top: 2em;
margin-bottom: 0.75em;
font-weight: 600;
line-height: 1.3;
}
.markdown-body h1 {
font-size: 2em;
border-bottom: 1px solid #334155;
padding-bottom: 0.3em;
}
.markdown-body h2 {
font-size: 1.5em;
border-bottom: 1px solid #334155;
padding-bottom: 0.3em;
}
.markdown-body h3 {
font-size: 1.25em;
}
.markdown-body h4 {
font-size: 1.1em;
}
.markdown-body p {
margin-top: 0;
margin-bottom: 1em;
color: #cbd5e1;
}
.markdown-body a {
color: #60a5fa;
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
color: #93c5fd;
}
.markdown-body strong {
color: #f1f5f9;
font-weight: 600;
}
.markdown-body em {
color: #e2e8f0;
}
.markdown-body code {
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
padding: 0.2em 0.4em;
font-size: 0.875em;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
color: #e2e8f0;
}
.markdown-body pre code {
background: transparent;
border: 0;
padding: 0;
font-size: 0.875em;
color: inherit;
}
.code-block-wrapper {
margin: 1.5em 0;
border: 1px solid #1e293b;
border-radius: 8px;
overflow: hidden;
}
.code-block-header {
background: #0f172a;
padding: 8px 16px;
border-bottom: 1px solid #1e293b;
display: flex;
align-items: center;
justify-content: space-between;
}
.code-language {
font-size: 12px;
color: #94a3b8;
text-transform: uppercase;
font-weight: 500;
}
.markdown-body blockquote {
border-left: 4px solid #475569;
margin: 1.5em 0;
padding: 0.5em 1em;
color: #94a3b8;
background: #0f172a;
border-radius: 0 8px 8px 0;
}
.markdown-body blockquote > :first-child {
margin-top: 0;
}
.markdown-body blockquote > :last-child {
margin-bottom: 0;
}
.markdown-body ul,
.markdown-body ol {
margin-top: 0;
margin-bottom: 1em;
padding-left: 2em;
}
.markdown-body ul {
list-style-type: disc;
}
.markdown-body ol {
list-style-type: decimal;
}
.markdown-body li {
margin: 0.25em 0;
}
.markdown-body li > p {
margin-bottom: 0.5em;
}
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin: 1.5em 0;
font-size: 0.875em;
}
.markdown-body table th,
.markdown-body table td {
border: 1px solid #334155;
padding: 8px 12px;
text-align: left;
}
.markdown-body table th {
background: #0f172a;
color: #f8fafc;
font-weight: 600;
}
.markdown-body table tr:nth-child(even) {
background: #0f172a;
}
.markdown-body table tr:hover {
background: #1e293b;
}
.markdown-body hr {
border: 0;
border-top: 1px solid #334155;
margin: 2em 0;
}
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1em 0;
}
.markdown-body input[type='checkbox'] {
margin-right: 0.5em;
}
/* Task lists */
.markdown-body ul.contains-task-list {
list-style-type: none;
padding-left: 0;
}
.markdown-body ul.contains-task-list li {
display: flex;
align-items: flex-start;
gap: 0.5em;
}
.markdown-body ul.contains-task-list li input[type='checkbox'] {
margin-top: 0.3em;
}
/* Scrollbar styling for dark theme */
.markdown-body ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.markdown-body ::-webkit-scrollbar-track {
background: #0f172a;
}
.markdown-body ::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
.markdown-body ::-webkit-scrollbar-thumb:hover {
background: #475569;
}
`}</style>
</DialogContent>
</Dialog>
);
}