Refactor content viewer components and use for multpart and request body (#333)

This commit is contained in:
Gregory Schier
2025-12-28 13:25:24 -08:00
committed by GitHub
parent 6869aa49ec
commit 394fbbd55d
16 changed files with 325 additions and 116 deletions

View File

@@ -1,31 +1,57 @@
import { type MultipartPart, parseMultipart } from '@mjackson/multipart-parser';
import type { HttpResponse } from '@yaakapp-internal/models';
import { useState } from 'react';
import { useResponseBodyBytes } from '../../hooks/useResponseBodyText';
import { getMimeTypeFromContentType, languageFromContentType } from '../../lib/contentType';
import { getContentTypeFromHeaders } from '../../lib/model_util';
import { Editor } from '../core/Editor/LazyEditor';
import { lazy, Suspense, useMemo, useState } from 'react';
import { languageFromContentType } from '../../lib/contentType';
import { Banner } from '../core/Banner';
import { Icon } from '../core/Icon';
import { LoadingIcon } from '../core/LoadingIcon';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { CsvViewerInner } from './CsvViewer';
import { AudioViewer } from './AudioViewer';
import { CsvViewer } from './CsvViewer';
import { ImageViewer } from './ImageViewer';
import { SvgViewer } from './SvgViewer';
import { TextViewer } from './TextViewer';
import { VideoViewer } from './VideoViewer';
import { WebPageViewer } from './WebPageViewer';
const PdfViewer = lazy(() => import('./PdfViewer').then((m) => ({ default: m.PdfViewer })));
interface Props {
response: HttpResponse;
data: Uint8Array;
boundary: string;
idPrefix?: string;
}
export function MultipartViewer({ response }: Props) {
const body = useResponseBodyBytes({ response });
export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Props) {
const [tab, setTab] = useState<string>();
if (body.data == null) return null;
const parseResult = useMemo(() => {
try {
const maxFileSize = 1024 * 1024 * 10; // 10MB
const parsed = parseMultipart(data, { boundary, maxFileSize });
const parts = Array.from(parsed);
return { parts, error: null };
} catch (err) {
return { parts: [], error: err instanceof Error ? err.message : String(err) };
}
}, [data, boundary]);
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown';
const { parts, error } = parseResult;
const maxFileSize = 1024 * 1024 * 10; // 10MB
const parsed = parseMultipart(body.data, { boundary, maxFileSize });
const parts = Array.from(parsed);
if (error) {
return (
<Banner color="danger" className="m-3">
Failed to parse multipart data: {error}
</Banner>
);
}
if (parts.length === 0) {
return (
<Banner color="info" className="m-3">
No multipart parts found
</Banner>
);
}
return (
<Tabs
@@ -47,14 +73,14 @@ export function MultipartViewer({ response }: Props) {
/>
</div>
) : part.filename ? (
<Icon icon="file_text" />
<Icon icon="file" />
) : null,
}))}
>
{parts.map((part, i) => (
<TabContent
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else to key on
key={response.id + part.name + i}
key={idPrefix + part.name + i}
value={part.name ?? ''}
className="pl-3 !pt-0"
>
@@ -66,19 +92,47 @@ export function MultipartViewer({ response }: Props) {
}
function Part({ part }: { part: MultipartPart }) {
const contentType = part.headers.get('content-type');
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const mimeType = part.headers.contentType.mediaType ?? null;
const contentTypeHeader = part.headers.get('content-type');
const { uint8Array, content, detectedLanguage } = useMemo(() => {
const uint8Array = new Uint8Array(part.arrayBuffer);
const content = new TextDecoder().decode(part.arrayBuffer);
const detectedLanguage = languageFromContentType(contentTypeHeader, content);
return { uint8Array, content, detectedLanguage };
}, [part, contentTypeHeader]);
if (mimeType?.match(/^image\/svg/i)) {
return <SvgViewer text={content} className="pb-2" />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={part.arrayBuffer} className="pb-2" />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
const content = new TextDecoder().decode(part.arrayBuffer);
return <CsvViewerInner text={content} />;
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={uint8Array} />;
}
const content = new TextDecoder().decode(part.arrayBuffer);
const language = languageFromContentType(contentType, content);
return <Editor readOnly defaultValue={content} language={language} stateKey={null} />;
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={uint8Array} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={content} />;
}
if (mimeType?.match(/^text\/html/i) || detectedLanguage === 'html') {
return <WebPageViewer html={content} />;
}
if (mimeType?.match(/pdf/i)) {
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={uint8Array} />
</Suspense>
);
}
return <TextViewer text={content} language={detectedLanguage} stateKey={null} />;
}