feat: add pptx, font, psd, epub converters and context-aware previews

- Wire presentationConverter (pptx read/write via pptxgenjs+jszip)
- Add fontConverter (ttf/otf/woff/woff2 via opentype.js + woff2-encoder)
- Add PSD support via ag-psd in imageConverter
- Add spreadsheetConverter (xlsx/xls/ods via SheetJS)
- Add ebookConverter (epub via jszip)
- Expand data converter with ini/env/properties/ndjson/jsonl/sql formats
- Add context-aware previews for pptx, epub, fonts, and psd in PreviewModal
- Remove unsupported .doc extension from fileDetector
- Replace Node-only wawoff2 with browser-compatible woff2-encoder
This commit is contained in:
noah
2026-03-09 20:18:41 +01:00
parent ade7807754
commit f15fd9f060
14 changed files with 2462 additions and 77 deletions
+707 -42
View File
@@ -11,18 +11,12 @@ interface PreviewModalProps {
onDownload: (file: UploadedFile) => void;
}
/** Formats we can show as text in a <pre> block */
const TEXT_FORMATS = new Set([
'txt', 'md', 'json', 'csv', 'tsv', 'xml', 'yaml', 'yml', 'toml',
'ini', 'env', 'properties', 'ndjson', 'jsonl', 'sql', 'rst', 'tex',
]);
/** Formats that render in an <iframe> */
const IFRAME_FORMATS = new Set(['pdf', 'html', 'htm']);
/* ─── Format classification ─── */
/** Image formats for <img> */
const IMAGE_FORMATS = new Set([
'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'avif', 'svg', 'ico', 'tiff', 'tif',
'heic', 'heif', 'psd',
]);
/** Audio formats for <audio> */
@@ -31,23 +25,371 @@ const AUDIO_FORMATS = new Set(['mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'opus'
/** Video formats for <video> */
const VIDEO_FORMATS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv']);
function getPreviewType(format: string): 'text' | 'iframe' | 'image' | 'audio' | 'video' | 'none' {
/** Formats rendered via iframe (PDF natively, HTML as-is) */
const IFRAME_FORMATS = new Set(['pdf', 'html', 'htm']);
type PreviewKind =
| 'image'
| 'audio'
| 'video'
| 'iframe' // pdf, html
| 'markdown' // .md → rendered HTML
| 'json' // syntax-highlighted JSON
| 'csv' // table view via papaparse
| 'tsv' // table view via papaparse
| 'xml' // syntax-highlighted XML
| 'yaml' // syntax-highlighted YAML
| 'toml' // syntax-highlighted TOML
| 'docx' // mammoth → HTML
| 'rtf' // strip RTF → text
| 'spreadsheet' // xlsx/xls/ods → table via SheetJS
| 'pptx' // extract slides text
| 'epub' // extract content from epub zip
| 'font' // font preview via opentype.js
| 'plaintext' // .txt, .ini, .env, .properties, .ndjson, .jsonl, .sql, etc.
| 'none';
const PLAINTEXT_FORMATS = new Set([
'txt', 'ini', 'env', 'properties', 'ndjson', 'jsonl', 'sql', 'rst', 'tex', 'log',
]);
function getPreviewKind(format: string): PreviewKind {
if (IMAGE_FORMATS.has(format)) return 'image';
if (IFRAME_FORMATS.has(format)) return 'iframe';
if (AUDIO_FORMATS.has(format)) return 'audio';
if (VIDEO_FORMATS.has(format)) return 'video';
if (TEXT_FORMATS.has(format)) return 'text';
if (IFRAME_FORMATS.has(format)) return 'iframe';
if (format === 'md') return 'markdown';
if (format === 'json') return 'json';
if (format === 'csv') return 'csv';
if (format === 'tsv') return 'tsv';
if (format === 'xml') return 'xml';
if (format === 'yaml' || format === 'yml') return 'yaml';
if (format === 'toml') return 'toml';
if (format === 'docx') return 'docx';
if (format === 'rtf') return 'rtf';
if (format === 'xlsx' || format === 'xls' || format === 'ods') return 'spreadsheet';
if (format === 'pptx') return 'pptx';
if (format === 'epub') return 'epub';
if (format === 'ttf' || format === 'otf' || format === 'woff' || format === 'woff2') return 'font';
if (PLAINTEXT_FORMATS.has(format)) return 'plaintext';
return 'none';
}
/* ─── Syntax highlighting helpers (no external deps) ─── */
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function highlightJson(raw: string): string {
try {
// Pretty-print first
const obj = JSON.parse(raw);
const pretty = JSON.stringify(obj, null, 2);
// Tokenize with regex
return pretty.replace(
/("(?:\\.|[^"\\])*")\s*:/g, // keys
'<span style="color:#9333ea">$1</span>:'
).replace(
/:\s*("(?:\\.|[^"\\])*")/g, // string values
': <span style="color:#059669">$1</span>'
).replace(
/:\s*(\d+\.?\d*)/g, // numbers
': <span style="color:#d97706">$1</span>'
).replace(
/:\s*(true|false)/g, // booleans
': <span style="color:#2563eb">$1</span>'
).replace(
/:\s*(null)/g, // null
': <span style="color:#9ca3af">$1</span>'
);
} catch {
return escapeHtml(raw);
}
}
function highlightXml(raw: string): string {
const escaped = escapeHtml(raw);
return escaped
// Tags: <tagName ... >
.replace(/&lt;(\/?)([\w:.-]+)/g, '&lt;$1<span style="color:#9333ea">$2</span>')
// Attributes: key="value"
.replace(/([\w:.-]+)=&quot;([^&]*)&quot;/g,
'<span style="color:#d97706">$1</span>=&quot;<span style="color:#059669">$2</span>&quot;')
// Comments
.replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span style="color:#9ca3af">$1</span>');
}
function highlightYaml(raw: string): string {
return escapeHtml(raw)
.split('\n')
.map(line => {
// Comments
if (/^\s*#/.test(line)) return `<span style="color:#9ca3af">${line}</span>`;
// Key: value
const match = line.match(/^(\s*)([\w.-]+)(\s*:\s*)(.*)/);
if (match) {
const [, indent, key, colon, val] = match;
let valHtml = val;
if (/^(true|false)$/i.test(val)) valHtml = `<span style="color:#2563eb">${val}</span>`;
else if (/^-?\d+\.?\d*$/.test(val)) valHtml = `<span style="color:#d97706">${val}</span>`;
else if (/^["'].*["']$/.test(val)) valHtml = `<span style="color:#059669">${val}</span>`;
else if (val === 'null' || val === '~') valHtml = `<span style="color:#9ca3af">${val}</span>`;
else if (val) valHtml = `<span style="color:#059669">${val}</span>`;
return `${indent}<span style="color:#9333ea">${key}</span>${colon}${valHtml}`;
}
// List items
if (/^\s*-\s/.test(line)) {
return line.replace(/^(\s*-\s+)(.*)/, '$1<span style="color:#059669">$2</span>');
}
return line;
})
.join('\n');
}
function highlightToml(raw: string): string {
return escapeHtml(raw)
.split('\n')
.map(line => {
// Comments
if (/^\s*#/.test(line)) return `<span style="color:#9ca3af">${line}</span>`;
// Section headers [section]
if (/^\s*\[/.test(line)) return `<span style="color:#2563eb;font-weight:600">${line}</span>`;
// Key = value
const match = line.match(/^(\s*)([\w.-]+)(\s*=\s*)(.*)/);
if (match) {
const [, indent, key, eq, val] = match;
let valHtml = val;
if (/^(true|false)$/i.test(val)) valHtml = `<span style="color:#2563eb">${val}</span>`;
else if (/^-?\d+\.?\d*$/.test(val)) valHtml = `<span style="color:#d97706">${val}</span>`;
else if (/^&quot;.*&quot;$/.test(val)) valHtml = `<span style="color:#059669">${val}</span>`;
else if (val) valHtml = `<span style="color:#059669">${val}</span>`;
return `${indent}<span style="color:#9333ea">${key}</span>${eq}${valHtml}`;
}
return line;
})
.join('\n');
}
function stripRtf(raw: string): string {
// Basic RTF stripping: remove RTF control words and groups, extract text
let result = raw
.replace(/\\par[d]?/g, '\n')
.replace(/\{\\[^{}]*\}/g, '') // remove groups like {\fonttbl ...}
.replace(/\\[a-z]+\d*\s?/gi, '') // remove control words like \b, \fs24
.replace(/[{}]/g, '') // remove remaining braces
.replace(/\\'([0-9a-fA-F]{2})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16)))
.trim();
// Clean up excessive newlines
result = result.replace(/\n{3,}/g, '\n\n');
return result;
}
/* ─── CSV/TSV table renderer ─── */
function CsvTable({ text, delimiter }: { text: string; delimiter: string }) {
const [rows, setRows] = useState<string[][]>([]);
const [hasHeader, setHasHeader] = useState(true);
useEffect(() => {
// Lazy-load papaparse
import('papaparse').then((Papa) => {
const result = Papa.default.parse(text, {
delimiter: delimiter === '\t' ? '\t' : delimiter,
skipEmptyLines: true,
});
setRows(result.data as string[][]);
});
}, [text, delimiter]);
if (rows.length === 0) return <div className="p-4 text-text-light font-mono text-sm">Parsing...</div>;
const headerRow = hasHeader ? rows[0] : null;
const bodyRows = hasHeader ? rows.slice(1) : rows;
const maxCols = Math.max(...rows.map(r => r.length));
return (
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<span className="font-mono text-[11px] text-text-light">{rows.length} rows x {maxCols} cols</span>
<button
className="font-mono text-[11px] px-2 py-0.5 rounded border border-border-soft text-text-mid hover:bg-bg-warm transition-colors"
onClick={() => setHasHeader(!hasHeader)}
>
{hasHeader ? 'Headers: ON' : 'Headers: OFF'}
</button>
</div>
<div className="overflow-auto max-h-[65vh] rounded-xl border border-border-soft">
<table className="w-full border-collapse bg-white text-[13px]">
{headerRow && (
<thead>
<tr>
<th className="px-3 py-2 text-left font-mono text-[11px] font-bold text-text-light bg-bg-warm border-b border-border-soft w-10">#</th>
{headerRow.map((cell, i) => (
<th
key={i}
className="px-3 py-2 text-left font-mono text-[11px] font-bold text-purple bg-bg-warm border-b border-border-soft whitespace-nowrap"
>
{cell}
</th>
))}
</tr>
</thead>
)}
<tbody>
{bodyRows.slice(0, 500).map((row, ri) => (
<tr key={ri} className={ri % 2 === 0 ? 'bg-white' : 'bg-bg-cream/40'}>
<td className="px-3 py-1.5 font-mono text-[11px] text-text-light border-b border-border-soft/50 w-10">{ri + 1}</td>
{Array.from({ length: maxCols }, (_, ci) => (
<td
key={ci}
className="px-3 py-1.5 font-mono text-[12px] text-text-dark border-b border-border-soft/50 whitespace-nowrap max-w-[300px] truncate"
title={row[ci] || ''}
>
{row[ci] || ''}
</td>
))}
</tr>
))}
</tbody>
</table>
{bodyRows.length > 500 && (
<div className="text-center py-2 font-mono text-[11px] text-text-light bg-bg-warm border-t border-border-soft">
Showing 500 of {bodyRows.length} rows
</div>
)}
</div>
</div>
);
}
/* ─── Rendered HTML viewer (for Markdown, DOCX) ─── */
function RenderedHtmlFrame({ html, title }: { html: string; title: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
if (!iframeRef.current) return;
const doc = iframeRef.current.contentDocument;
if (!doc) return;
// Build a styled HTML page inside the iframe
doc.open();
doc.write(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.7;
color: #2d1f14;
padding: 24px 32px;
margin: 0;
max-width: 720px;
font-size: 15px;
background: #fffcf8;
}
h1, h2, h3, h4, h5, h6 {
font-family: Georgia, 'Times New Roman', serif;
color: #2d1f14;
margin: 1.4em 0 0.6em;
line-height: 1.3;
}
h1 { font-size: 1.8em; border-bottom: 2px solid rgba(180,140,100,0.15); padding-bottom: 8px; }
h2 { font-size: 1.4em; border-bottom: 1px solid rgba(180,140,100,0.1); padding-bottom: 6px; }
h3 { font-size: 1.2em; }
p { margin: 0.8em 0; }
a { color: #9333ea; text-decoration: underline; }
code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
background: rgba(180,140,100,0.08);
padding: 2px 5px;
border-radius: 4px;
font-size: 0.9em;
}
pre {
background: #2d1f14;
color: #f5e6d3;
padding: 16px 20px;
border-radius: 10px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
}
pre code {
background: none;
padding: 0;
color: inherit;
}
blockquote {
border-left: 3px solid #a78bfa;
margin: 1em 0;
padding: 8px 16px;
background: rgba(167,139,250,0.06);
color: #7a6552;
}
ul, ol { padding-left: 24px; }
li { margin: 4px 0; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid rgba(180,140,100,0.15); padding: 8px 12px; text-align: left; }
th { background: rgba(180,140,100,0.06); font-weight: 600; }
img { max-width: 100%; height: auto; border-radius: 8px; }
hr { border: none; height: 1px; background: rgba(180,140,100,0.15); margin: 1.5em 0; }
</style>
</head>
<body>${html}</body>
</html>`);
doc.close();
}, [html]);
return (
<iframe
ref={iframeRef}
className="w-full h-[70vh] border-none"
title={title}
sandbox="allow-same-origin"
/>
);
}
/* ─── Syntax-highlighted code block ─── */
function HighlightedCode({ html, label }: { html: string; label: string }) {
return (
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<span className="inline-flex items-center px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-widest rounded bg-purple/10 text-purple border border-purple/15">
{label}
</span>
</div>
<pre
className="w-full p-5 bg-white rounded-xl border border-border-soft font-mono text-[13px] leading-relaxed overflow-auto max-h-[65vh] whitespace-pre-wrap break-words"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}
/* ─── Main component ─── */
export function PreviewModal({ file, onClose, onDownload }: PreviewModalProps) {
const [textContent, setTextContent] = useState<string | null>(null);
const [renderedContent, setRenderedContent] = useState<{
kind: 'html' | 'highlighted' | 'table' | 'spreadsheet' | 'plaintext' | 'font';
html?: string;
text?: string;
label?: string;
delimiter?: string;
sheetData?: { headers: string[]; rows: string[][] };
fontDataUrl?: string;
} | null>(null);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const overlayRef = useRef<HTMLDivElement>(null);
const targetFormat = file?.targetFormat || '';
const previewType = getPreviewType(targetFormat);
const previewKind = getPreviewKind(targetFormat);
// Load preview content
useEffect(() => {
@@ -56,40 +398,273 @@ export function PreviewModal({ file, onClose, onDownload }: PreviewModalProps) {
return;
}
let cancelled = false;
setLoading(true);
setTextContent(null);
setRenderedContent(null);
const type = getPreviewType(file.targetFormat);
const kind = getPreviewKind(file.targetFormat);
if (type === 'text') {
// Read blob as text
const reader = new FileReader();
reader.onload = (e) => {
setTextContent(e.target?.result as string);
setLoading(false);
};
reader.onerror = () => {
setTextContent('[Failed to read file content]');
setLoading(false);
};
reader.readAsText(file.convertedBlob);
} else if (type === 'image' || type === 'iframe' || type === 'audio' || type === 'video') {
if (kind === 'image' || kind === 'iframe' || kind === 'audio' || kind === 'video') {
const url = URL.createObjectURL(file.convertedBlob);
setBlobUrl(url);
setLoading(false);
} else if (kind === 'docx') {
// Convert DOCX blob to HTML via mammoth (lazy loaded)
import('mammoth').then(async (mammoth) => {
if (cancelled) return;
try {
const arrayBuffer = await file.convertedBlob!.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer });
if (!cancelled) {
setRenderedContent({ kind: 'html', html: result.value });
setLoading(false);
}
} catch {
if (!cancelled) {
setRenderedContent({ kind: 'plaintext', text: '[Failed to parse DOCX for preview]' });
setLoading(false);
}
}
});
} else if (kind === 'spreadsheet') {
// Parse xlsx/xls/ods via SheetJS and render as table
import('xlsx').then(async (XLSX) => {
if (cancelled) return;
try {
const buffer = await file.convertedBlob!.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const jsonData = XLSX.utils.sheet_to_json<string[]>(ws, { header: 1 });
const headers = (jsonData[0] || []).map(String);
const rows = jsonData.slice(1).map(row => row.map(String));
if (!cancelled) {
setRenderedContent({ kind: 'spreadsheet', sheetData: { headers, rows } });
setLoading(false);
}
} catch {
if (!cancelled) {
setRenderedContent({ kind: 'plaintext', text: '[Failed to parse spreadsheet for preview]' });
setLoading(false);
}
}
});
} else if (kind === 'pptx') {
// Extract slide text from PPTX via jszip
import('jszip').then(async (JSZipModule) => {
if (cancelled) return;
try {
const JSZip = JSZipModule.default;
const buffer = await file.convertedBlob!.arrayBuffer();
const zip = await JSZip.loadAsync(buffer);
const slideFiles = Object.keys(zip.files)
.filter(f => /^ppt\/slides\/slide\d+\.xml$/i.test(f))
.sort((a, b) => {
const numA = parseInt(a.match(/slide(\d+)/)?.[1] || '0');
const numB = parseInt(b.match(/slide(\d+)/)?.[1] || '0');
return numA - numB;
});
const slidesHtml: string[] = [];
for (const slideFile of slideFiles) {
const content = await zip.file(slideFile)?.async('string');
if (!content) continue;
const texts: string[] = [];
const textRegex = /<a:t>([^<]*)<\/a:t>/g;
let match;
while ((match = textRegex.exec(content)) !== null) {
if (match[1].trim()) texts.push(escapeHtml(match[1]));
}
const slideNum = slideFile.match(/slide(\d+)/)?.[1] || '?';
slidesHtml.push(`<div style="margin:1.5em 0;padding:1em 1.5em;border:1px solid rgba(180,140,100,0.15);border-radius:12px;background:white"><h3 style="margin:0 0 0.5em;color:#9333ea;font-size:14px;font-family:monospace">Slide ${slideNum}</h3><p style="margin:0;line-height:1.6;color:#2d1f14">${texts.join('<br/>')}</p></div>`);
}
if (!cancelled) {
const html = slidesHtml.length > 0
? `<h2 style="font-family:Georgia,serif;color:#2d1f14;margin-bottom:0.5em">${escapeHtml(file.convertedName || file.name)}</h2><p style="color:#7a6552;font-size:13px;font-family:monospace">${slideFiles.length} slides</p>${slidesHtml.join('')}`
: '<p style="color:#7a6552">No slide content found in this presentation.</p>';
setRenderedContent({ kind: 'html', html });
setLoading(false);
}
} catch {
if (!cancelled) {
setRenderedContent({ kind: 'plaintext', text: '[Failed to parse PPTX for preview]' });
setLoading(false);
}
}
});
} else if (kind === 'epub') {
// Extract EPUB content via jszip
import('jszip').then(async (JSZipModule) => {
if (cancelled) return;
try {
const JSZip = JSZipModule.default;
const buffer = await file.convertedBlob!.arrayBuffer();
const zip = await JSZip.loadAsync(buffer);
// Find XHTML content files in the EPUB
const htmlFiles = Object.keys(zip.files)
.filter(f => /\.(xhtml|html|htm)$/i.test(f) && !f.includes('nav') && !f.includes('toc'))
.sort();
const contentParts: string[] = [];
for (const htmlFile of htmlFiles.slice(0, 20)) { // Limit to 20 chapters
const content = await zip.file(htmlFile)?.async('string');
if (!content) continue;
// Extract body content
const bodyMatch = content.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
if (bodyMatch) {
contentParts.push(bodyMatch[1]);
}
}
if (!cancelled) {
const html = contentParts.length > 0
? contentParts.join('<hr style="border:none;height:1px;background:rgba(180,140,100,0.15);margin:2em 0"/>')
: '<p style="color:#7a6552">No readable content found in this EPUB.</p>';
setRenderedContent({ kind: 'html', html });
setLoading(false);
}
} catch {
if (!cancelled) {
setRenderedContent({ kind: 'plaintext', text: '[Failed to parse EPUB for preview]' });
setLoading(false);
}
}
});
} else if (kind === 'font') {
// Preview font using opentype.js — render sample glyphs to canvas
import('opentype.js').then(async (opentype) => {
if (cancelled) return;
try {
const buffer = await file.convertedBlob!.arrayBuffer();
const font = opentype.parse(buffer);
// Create a canvas with sample text
const canvas = document.createElement('canvas');
const scale = 2; // Retina
canvas.width = 700 * scale;
canvas.height = 500 * scale;
const ctx = canvas.getContext('2d')!;
ctx.scale(scale, scale);
// Background
ctx.fillStyle = '#fffcf8';
ctx.fillRect(0, 0, 700, 500);
// Font name
ctx.fillStyle = '#2d1f14';
ctx.font = '14px monospace';
ctx.fillText(`${font.names.fontFamily?.en || 'Font'}${font.names.fontSubfamily?.en || ''}`, 24, 32);
// Draw sample text at various sizes
const samples = [
{ text: 'ABCDEFGHIJKLM', size: 48, y: 90 },
{ text: 'NOPQRSTUVWXYZ', size: 48, y: 145 },
{ text: 'abcdefghijklm', size: 44, y: 200 },
{ text: 'nopqrstuvwxyz', size: 44, y: 250 },
{ text: '0123456789', size: 44, y: 305 },
{ text: 'The quick brown fox jumps', size: 32, y: 360 },
{ text: 'over the lazy dog. 0123456789', size: 24, y: 405 },
{ text: '!@#$%^&*()_+-=[]{}|;:\'",.<>?', size: 24, y: 445 },
];
for (const sample of samples) {
const path = font.getPath(sample.text, 24, sample.y, sample.size);
path.fill = '#2d1f14';
path.draw(ctx);
}
const dataUrl = canvas.toDataURL('image/png');
if (!cancelled) {
setRenderedContent({ kind: 'font', fontDataUrl: dataUrl, label: font.names.fontFamily?.en || 'Font' });
setLoading(false);
}
} catch {
if (!cancelled) {
setRenderedContent({ kind: 'plaintext', text: '[Failed to parse font for preview]' });
setLoading(false);
}
}
});
} else if (kind === 'markdown') {
// Render Markdown → HTML via marked
const reader = new FileReader();
reader.onload = async (e) => {
if (cancelled) return;
try {
const { marked } = await import('marked');
const raw = e.target?.result as string;
const html = await marked.parse(raw, { breaks: true, gfm: true });
if (!cancelled) {
setRenderedContent({ kind: 'html', html });
setLoading(false);
}
} catch {
if (!cancelled) {
setRenderedContent({ kind: 'plaintext', text: e.target?.result as string });
setLoading(false);
}
}
};
reader.onerror = () => {
if (!cancelled) {
setRenderedContent({ kind: 'plaintext', text: '[Failed to read file]' });
setLoading(false);
}
};
reader.readAsText(file.convertedBlob);
} else if (kind === 'json' || kind === 'xml' || kind === 'yaml' || kind === 'toml' || kind === 'rtf' || kind === 'csv' || kind === 'tsv' || kind === 'plaintext') {
// Read as text, then process
const reader = new FileReader();
reader.onload = (e) => {
if (cancelled) return;
const raw = e.target?.result as string;
switch (kind) {
case 'json':
setRenderedContent({ kind: 'highlighted', html: highlightJson(raw), label: 'JSON' });
break;
case 'xml':
setRenderedContent({ kind: 'highlighted', html: highlightXml(raw), label: 'XML' });
break;
case 'yaml':
setRenderedContent({ kind: 'highlighted', html: highlightYaml(raw), label: 'YAML' });
break;
case 'toml':
setRenderedContent({ kind: 'highlighted', html: highlightToml(raw), label: 'TOML' });
break;
case 'rtf':
setRenderedContent({ kind: 'plaintext', text: stripRtf(raw) });
break;
case 'csv':
setRenderedContent({ kind: 'table', text: raw, delimiter: ',' });
break;
case 'tsv':
setRenderedContent({ kind: 'table', text: raw, delimiter: '\t' });
break;
default:
setRenderedContent({ kind: 'plaintext', text: raw });
}
setLoading(false);
};
reader.onerror = () => {
if (!cancelled) {
setRenderedContent({ kind: 'plaintext', text: '[Failed to read file content]' });
setLoading(false);
}
};
reader.readAsText(file.convertedBlob);
} else {
setLoading(false);
}
return () => {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [file?.convertedBlob, file?.targetFormat]);
// Cleanup blob URL on unmount
// Clean up blob URL
useEffect(() => {
return () => {
if (blobUrl) URL.revokeObjectURL(blobUrl);
@@ -117,6 +692,23 @@ export function PreviewModal({ file, onClose, onDownload }: PreviewModalProps) {
const convertedSize = file.convertedBlob ? formatFileSize(file.convertedBlob.size) : '';
/* ─── Format label + color for the badge ─── */
const kindBadge: Record<string, { label: string; color: string }> = {
markdown: { label: 'Rendered', color: 'purple' },
json: { label: 'Highlighted', color: 'orange' },
csv: { label: 'Table', color: 'blue' },
tsv: { label: 'Table', color: 'blue' },
xml: { label: 'Highlighted', color: 'teal' },
yaml: { label: 'Highlighted', color: 'teal' },
toml: { label: 'Highlighted', color: 'teal' },
docx: { label: 'Rendered', color: 'purple' },
spreadsheet: { label: 'Table', color: 'mint' },
pptx: { label: 'Slides', color: 'orange' },
epub: { label: 'Rendered', color: 'purple' },
font: { label: 'Glyphs', color: 'teal' },
};
const badge = kindBadge[previewKind];
return (
<AnimatePresence>
{file && (
@@ -146,6 +738,19 @@ export function PreviewModal({ file, onClose, onDownload }: PreviewModalProps) {
</svg>
<span className="font-mono text-[11px] font-bold uppercase tracking-wider">Preview</span>
</div>
{badge && (
<span
className={`font-mono text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 rounded border
${badge.color === 'purple' ? 'bg-purple/10 text-purple border-purple/15' : ''}
${badge.color === 'orange' ? 'bg-orange/10 text-orange border-orange/15' : ''}
${badge.color === 'blue' ? 'bg-blue/10 text-blue border-blue/15' : ''}
${badge.color === 'teal' ? 'bg-teal/10 text-teal border-teal/15' : ''}
${badge.color === 'mint' ? 'bg-mint/10 text-mint border-mint/15' : ''}
`}
>
{badge.label}
</span>
)}
<div className="min-w-0">
<p className="text-sm font-semibold text-text-dark truncate" title={file.convertedName || ''}>
{file.convertedName || file.name}
@@ -189,7 +794,7 @@ export function PreviewModal({ file, onClose, onDownload }: PreviewModalProps) {
<span className="font-mono text-xs text-text-light">Loading preview...</span>
</div>
</div>
) : previewType === 'image' && blobUrl ? (
) : previewKind === 'image' && blobUrl ? (
<div className="flex items-center justify-center p-6 min-h-[300px]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -198,14 +803,14 @@ export function PreviewModal({ file, onClose, onDownload }: PreviewModalProps) {
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-[0_4px_20px_rgba(0,0,0,0.08)]"
/>
</div>
) : previewType === 'iframe' && blobUrl ? (
) : previewKind === 'iframe' && blobUrl ? (
<iframe
src={blobUrl}
className="w-full h-[70vh] border-none"
title="File preview"
sandbox="allow-same-origin"
/>
) : previewType === 'audio' && blobUrl ? (
) : previewKind === 'audio' && blobUrl ? (
<div className="flex flex-col items-center justify-center gap-6 p-10 min-h-[250px]">
<div className="flex items-center justify-center w-20 h-20 rounded-full bg-purple/10 border border-purple/20">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" strokeWidth="1.5">
@@ -221,7 +826,7 @@ export function PreviewModal({ file, onClose, onDownload }: PreviewModalProps) {
{file.convertedName}
</p>
</div>
) : previewType === 'video' && blobUrl ? (
) : previewKind === 'video' && blobUrl ? (
<div className="flex items-center justify-center p-4">
<video
controls
@@ -232,12 +837,72 @@ export function PreviewModal({ file, onClose, onDownload }: PreviewModalProps) {
Your browser does not support video playback.
</video>
</div>
) : previewType === 'text' && textContent !== null ? (
) : renderedContent?.kind === 'html' && renderedContent.html ? (
<RenderedHtmlFrame html={renderedContent.html} title={file.convertedName || 'Preview'} />
) : renderedContent?.kind === 'highlighted' && renderedContent.html ? (
<HighlightedCode html={renderedContent.html} label={renderedContent.label || ''} />
) : renderedContent?.kind === 'table' && renderedContent.text ? (
<CsvTable text={renderedContent.text} delimiter={renderedContent.delimiter || ','} />
) : renderedContent?.kind === 'spreadsheet' && renderedContent.sheetData ? (
<div className="p-4">
<pre className="w-full p-4 bg-white rounded-xl border border-border-soft font-mono text-[13px] leading-relaxed text-text-dark overflow-auto max-h-[70vh] whitespace-pre-wrap break-words">
{textContent.length > 100000
? textContent.slice(0, 100000) + '\n\n... [truncated — file too large for preview]'
: textContent}
<div className="flex items-center gap-2 mb-3">
<span className="inline-flex items-center px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-widest rounded bg-mint/10 text-mint border border-mint/15">
Spreadsheet
</span>
<span className="font-mono text-[11px] text-text-light">
{renderedContent.sheetData.rows.length} rows x {renderedContent.sheetData.headers.length} cols
</span>
</div>
<div className="overflow-auto max-h-[65vh] rounded-xl border border-border-soft">
<table className="w-full border-collapse bg-white text-[13px]">
<thead>
<tr>
<th className="px-3 py-2 text-left font-mono text-[11px] font-bold text-text-light bg-bg-warm border-b border-border-soft w-10">#</th>
{renderedContent.sheetData.headers.map((h, i) => (
<th key={i} className="px-3 py-2 text-left font-mono text-[11px] font-bold text-purple bg-bg-warm border-b border-border-soft whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{renderedContent.sheetData.rows.slice(0, 500).map((row, ri) => (
<tr key={ri} className={ri % 2 === 0 ? 'bg-white' : 'bg-bg-cream/40'}>
<td className="px-3 py-1.5 font-mono text-[11px] text-text-light border-b border-border-soft/50 w-10">{ri + 1}</td>
{renderedContent.sheetData!.headers.map((_, ci) => (
<td key={ci} className="px-3 py-1.5 font-mono text-[12px] text-text-dark border-b border-border-soft/50 whitespace-nowrap max-w-[300px] truncate" title={row[ci] || ''}>
{row[ci] || ''}
</td>
))}
</tr>
))}
</tbody>
</table>
{renderedContent.sheetData.rows.length > 500 && (
<div className="text-center py-2 font-mono text-[11px] text-text-light bg-bg-warm border-t border-border-soft">
Showing 500 of {renderedContent.sheetData.rows.length} rows
</div>
)}
</div>
</div>
) : renderedContent?.kind === 'font' && renderedContent.fontDataUrl ? (
<div className="flex flex-col items-center justify-center p-6 min-h-[300px] gap-3">
<span className="inline-flex items-center px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-widest rounded bg-teal/10 text-teal border border-teal/15">
{renderedContent.label}
</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={renderedContent.fontDataUrl}
alt="Font preview"
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-[0_4px_20px_rgba(0,0,0,0.08)]"
/>
</div>
) : renderedContent?.kind === 'plaintext' && renderedContent.text !== undefined ? (
<div className="p-4">
<pre className="w-full p-5 bg-white rounded-xl border border-border-soft font-mono text-[13px] leading-relaxed text-text-dark overflow-auto max-h-[65vh] whitespace-pre-wrap break-words">
{renderedContent.text.length > 100000
? renderedContent.text.slice(0, 100000) + '\n\n... [truncated — file too large for preview]'
: renderedContent.text}
</pre>
</div>
) : (