SPB Git

Repository Public

previews.tsx 7.9 KB 159 lines tsx

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
Files main
Menu

Theme