Repository Public
1'use client';2import { useEffect, useState } from 'react';3import ReactMarkdown from 'react-markdown';4import remarkGfm from 'remark-gfm';5import { Download, FileQuestion } from 'lucide-react';6import { api, downloadUrl, errorMessage, qs } from '@/lib/api';7import { fmtBytes, fmtDateTime } from '@/lib/format';8import { Spinner } from '@/components/ui/misc';9import type { Buffer } from './editor-store';1011/** Markdown is rendered by react-markdown WITHOUT raw HTML: embedded HTML is shown as text. */12export function MarkdownPreview({ source }: { source: string }) {13 return (14 <div className="h-full overflow-y-auto px-5 py-4" data-testid="markdown-preview">15 <div16 className="mx-auto max-w-3xl text-sm leading-relaxed text-text [&_a]:text-accent [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-line-3 [&_blockquote]:pl-3 [&_blockquote]:text-text-2 [&_code]:rounded-[4px] [&_code]:bg-surface-2 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[12.5px] [&_h1]:mb-3 [&_h1]:mt-5 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:mt-5 [&_h2]:text-base [&_h2]:font-semibold [&_h3]:mb-2 [&_h3]:mt-4 [&_h3]:font-semibold [&_hr]:my-4 [&_hr]:border-line-2 [&_img]:max-w-full [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:my-2 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:border [&_pre]:border-line-2 [&_pre]:bg-surface [&_pre]:p-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_table]:my-3 [&_table]:border-collapse [&_td]:border [&_td]:border-line-2 [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-line-2 [&_th]:bg-surface [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ul.contains-task-list]:list-none [&_ul.contains-task-list]:pl-1"17 >18 <ReactMarkdown19 remarkPlugins={[remarkGfm]}20 components={{21 a: ({ href, children }) => (22 <a href={href} target="_blank" rel="noopener noreferrer nofollow">23 {children}24 </a>25 ),26 // Remote images would violate the CSP (img-src 'self'); show the alt text instead.27 img: ({ alt }) => <span className="text-muted">[image: {alt || 'no description'}]</span>,28 }}29 >30 {source}31 </ReactMarkdown>32 </div>33 </div>34 );35}3637export function JsonPretty({ source }: { source: string }) {38 let text: string;39 let invalid = false;40 try {41 text = JSON.stringify(JSON.parse(source), null, 2);42 } catch (e) {43 invalid = true;44 text = e instanceof Error ? e.message : 'Invalid JSON';45 }46 return (47 <div className="h-full overflow-auto p-4" data-testid="json-pretty">48 {invalid && <p className="mb-2 text-xs text-error">This file is not valid JSON:</p>}49 <pre className="font-mono text-[12.5px] leading-relaxed text-text">{text}</pre>50 </div>51 );52}5354export function FileMeta({ buffer, reason }: { buffer: Buffer; reason: string }) {55 return (56 <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center" data-testid="file-meta">57 <FileQuestion className="size-6 text-muted" />58 <p className="text-sm font-medium">{reason}</p>59 <dl className="tnum grid grid-cols-[auto_auto] gap-x-4 gap-y-1 text-left text-xs">60 <dt className="text-muted">Path</dt>61 <dd className="break-all font-mono text-text-2">{buffer.path}</dd>62 <dt className="text-muted">Size</dt>63 <dd className="text-text-2">{fmtBytes(buffer.size)}</dd>64 <dt className="text-muted">Modified</dt>65 <dd className="text-text-2">{fmtDateTime(buffer.mtimeMs)}</dd>66 </dl>67 <DownloadLink projectId={buffer.projectId} path={buffer.path} />68 </div>69 );70}71export function DownloadLink({ projectId, path, label = 'Download' }: { projectId: string; path: string; label?: string }) {72 return (73 <a href={downloadUrl(projectId, path)} download className="inline-flex h-8 items-center gap-1.5 rounded-sm border border-line-2 bg-surface-2 px-3 text-[13px] hover:border-line-3 pointer-coarse:h-11">74 <Download className="size-3.5" /> {label}75 </a>76 );77}7879const IMAGE_MIME: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', ico: 'image/x-icon' };8081/**82 * Downloads are served as application/octet-stream (attachment, nosniff), so the image is83 * fetched and shown through a blob: URL typed from its extension (CSP: img-src blob:).84 * SVG is deliberately not previewed as an image.85 */86export function ImagePreview({ buffer }: { buffer: Buffer }) {87 const [url, setUrl] = useState<string | null>(null);88 const [failed, setFailed] = useState(false);89 useEffect(() => {90 let revoke: string | null = null;91 let cancelled = false;92 setFailed(false);93 setUrl(null);94 const ext = buffer.path.split('.').pop()?.toLowerCase() ?? '';95 api96 .raw(`/projects/${buffer.projectId}/download${qs({ path: buffer.path })}`)97 .then((r) => r.blob())98 .then((blob) => {99 if (cancelled) return;100 revoke = URL.createObjectURL(new Blob([blob], { type: IMAGE_MIME[ext] ?? 'application/octet-stream' }));101 setUrl(revoke);102 })103 .catch(() => !cancelled && setFailed(true));104 return () => {105 cancelled = true;106 if (revoke) URL.revokeObjectURL(revoke);107 };108 }, [buffer.projectId, buffer.path]);109 if (failed) return <FileMeta buffer={buffer} reason="This image cannot be displayed" />;110 if (!url) return <div className="flex h-full items-center justify-center"><Spinner /></div>;111 return (112 <div className="flex h-full flex-col items-center justify-center gap-3 overflow-auto p-4" data-testid="image-preview">113 {/* eslint-disable-next-line @next/next/no-img-element -- blob URL; no optimisation in a static export */}114 <img src={url} alt={buffer.path} onError={() => setFailed(true)} className="max-h-[80%] min-h-8 min-w-8 max-w-full rounded-sm border border-line-2 bg-surface-2 object-contain [image-rendering:pixelated]" />115 <DownloadLink projectId={buffer.projectId} path={buffer.path} />116 </div>117 );118}119120/**121 * PDF preview through a same-origin blob: URL in an <iframe>. If the Content-Security-Policy122 * forbids it (frame-src), the violation is detected and a download link is offered instead.123 */124export function PdfPreview({ buffer }: { buffer: Buffer }) {125 const [url, setUrl] = useState<string | null>(null);126 const [state, setState] = useState<'loading' | 'ok' | 'blocked' | 'error'>('loading');127 const [error, setError] = useState('');128 useEffect(() => {129 let revoke: string | null = null;130 let cancelled = false;131 const onViolation = (e: SecurityPolicyViolationEvent) => {132 if (/frame-src|child-src|default-src/.test(e.effectiveDirective) && e.blockedURI.startsWith('blob')) setState('blocked');133 };134 document.addEventListener('securitypolicyviolation', onViolation);135 api136 .raw(`/projects/${buffer.projectId}/download${qs({ path: buffer.path })}`)137 .then((r) => r.blob())138 .then((blob) => {139 if (cancelled) return;140 revoke = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' }));141 setUrl(revoke);142 setState('ok');143 })144 .catch((e: unknown) => {145 setError(errorMessage(e));146 setState('error');147 });148 return () => {149 cancelled = true;150 document.removeEventListener('securitypolicyviolation', onViolation);151 if (revoke) URL.revokeObjectURL(revoke);152 };153 }, [buffer.projectId, buffer.path]);154 if (state === 'loading') return <div className="flex h-full items-center justify-center"><Spinner /></div>;155 if (state === 'error') return <FileMeta buffer={buffer} reason={`Could not load the PDF: ${error}`} />;156 if (state === 'blocked' || !url) return <FileMeta buffer={buffer} reason="PDF preview is not allowed by the security policy" />;157 return <iframe src={url} title={buffer.path} className="h-full w-full border-0 bg-white" />;158}159