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

@@ -326,8 +326,14 @@ async fn build_multipart_body(
if file_path.is_empty() {
// Text field
let header =
format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n{}", name, value);
let header = if !content_type.is_empty() {
format!(
"Content-Disposition: form-data; name=\"{}\"\r\nContent-Type: {}\r\n\r\n{}",
name, content_type, value
)
} else {
format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n{}", name, value)
};
let header_bytes = header.into_bytes();
total_size += header_bytes.len();
readers.push(ReaderType::Bytes(header_bytes));

View File

@@ -6,6 +6,7 @@ import { useLocalStorage } from 'react-use';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { getMimeTypeFromContentType } from '../lib/contentType';
import { getContentTypeFromHeaders } from '../lib/model_util';
@@ -216,19 +217,19 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<SvgViewer response={activeResponse} />
<HttpSvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
) : mimeType?.match(/^multipart/i) ? (
<MultipartViewer response={activeResponse} />
) : mimeType?.match(/^multipart/i) && viewMode === 'pretty' ? (
<HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) ? (
<CsvViewer className="pb-2" response={activeResponse} />
<HttpCsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
@@ -284,3 +285,28 @@ function EnsureCompleteResponse({
return <Component bodyPath={response.bodyPath} />;
}
function HttpSvgViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyText({ response, filter: null });
if (!body.data) return null;
return <SvgViewer text={body.data} />;
}
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
const body = useResponseBodyText({ response, filter: null });
return <CsvViewer text={body.data ?? null} className={className} />;
}
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyBytes({ response });
if (body.data == null) return null;
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown';
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
}

View File

@@ -1,9 +1,21 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { lazy, Suspense } from 'react';
import { useHttpRequestBody } from '../hooks/useHttpRequestBody';
import { languageFromContentType } from '../lib/contentType';
import { getMimeTypeFromContentType, languageFromContentType } from '../lib/contentType';
import { EmptyStateText } from './EmptyStateText';
import { Editor } from './core/Editor/LazyEditor';
import { LoadingIcon } from './core/LoadingIcon';
import { AudioViewer } from './responseViewers/AudioViewer';
import { CsvViewer } from './responseViewers/CsvViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { MultipartViewer } from './responseViewers/MultipartViewer';
import { SvgViewer } from './responseViewers/SvgViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { VideoViewer } from './responseViewers/VideoViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
const PdfViewer = lazy(() =>
import('./responseViewers/PdfViewer').then((m) => ({ default: m.PdfViewer })),
);
interface Props {
response: HttpResponse;
@@ -32,21 +44,59 @@ function RequestBodyViewerInner({ response }: Props) {
return <EmptyStateText>No request body</EmptyStateText>;
}
const { bodyText } = data;
const { bodyText, body } = data;
// Try to detect language from content-type header that was sent
const contentTypeHeader = response.requestHeaders.find(
(h) => h.name.toLowerCase() === 'content-type',
);
const contentType = contentTypeHeader?.value ?? null;
const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
const language = languageFromContentType(contentType, bodyText);
// Route to appropriate viewer based on content type
if (mimeType?.match(/^multipart/i)) {
const boundary = contentType?.split('boundary=')[1] ?? 'unknown';
// Create a copy because parseMultipart may detach the buffer
const bodyCopy = new Uint8Array(body);
return (
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
);
}
if (mimeType?.match(/^image\/svg/i)) {
return <SvgViewer text={bodyText} />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={body.buffer} />;
}
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={body} />;
}
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={body} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={bodyText} />;
}
if (mimeType?.match(/^text\/html/i)) {
return <WebPageViewer html={bodyText} />;
}
if (mimeType?.match(/pdf/i)) {
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={body} />
</Suspense>
);
}
return (
<Editor
readOnly
defaultValue={bodyText}
language={language}
stateKey={`request.body.${response.id}`}
/>
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
);
}

View File

@@ -20,7 +20,7 @@ export default function RouteError({ error }: { error: unknown }) {
{stack && (
<DetailsBanner
color="secondary"
className="mt-3 select-auto text-xs"
className="mt-3 select-auto text-xs max-h-[40vh]"
summary="Stack Trace"
>
<div className="mt-2 text-xs">{stack}</div>

View File

@@ -3,12 +3,14 @@ import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
}
export function FormattedError({ children }: Props) {
export function FormattedError({ children, className }: Props) {
return (
<pre
className={classNames(
className,
'cursor-text select-auto',
'[&_*]:cursor-text [&_*]:select-auto',
'font-mono text-sm w-full bg-surface-highlight p-3 rounded',

View File

@@ -111,7 +111,7 @@ import {
Rows2Icon,
SaveIcon,
SearchIcon,
SendHorizonalIcon,
SendHorizontalIcon,
SettingsIcon,
ShieldAlertIcon,
ShieldCheckIcon,
@@ -245,7 +245,7 @@ const icons = {
rows_2: Rows2Icon,
save: SaveIcon,
search: SearchIcon,
send_horizontal: SendHorizonalIcon,
send_horizontal: SendHorizontalIcon,
settings: SettingsIcon,
shield: ShieldIcon,
shield_check: ShieldCheckIcon,

View File

@@ -1,11 +1,26 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
interface Props {
bodyPath: string;
bodyPath?: string;
data?: Uint8Array;
}
export function AudioViewer({ bodyPath }: Props) {
const src = convertFileSrc(bodyPath);
export function AudioViewer({ bodyPath, data }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
const blob = new Blob([data], { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
// biome-ignore lint/a11y/useMediaCaption: none
return <audio className="w-full" controls src={src} />;

View File

@@ -1,20 +1,17 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import Papa from 'papaparse';
import { useMemo } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
interface Props {
response: HttpResponse;
text: string | null;
className?: string;
}
export function CsvViewer({ response, className }: Props) {
const body = useResponseBodyText({ response, filter: null });
export function CsvViewer({ text, className }: Props) {
return (
<div className="overflow-auto h-full">
<CsvViewerInner text={body.data ?? null} className={className} />
<CsvViewerInner text={text} className={className} />
</div>
);
}

View File

@@ -1,7 +1,9 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useMemo, useState } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { languageFromContentType } from '../../lib/contentType';
import { getContentTypeFromHeaders } from '../../lib/model_util';
import type { EditorProps } from '../core/Editor/Editor';
import { EmptyStateText } from '../EmptyStateText';
import { TextViewer } from './TextViewer';
import { WebPageViewer } from './WebPageViewer';
@@ -22,19 +24,54 @@ export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Prop
}
if (language === 'html' && pretty) {
return <WebPageViewer response={response} />;
return <WebPageViewer html={rawTextBody.data ?? ''} baseUrl={response.url} />;
}
if (rawTextBody.data == null) {
return <EmptyStateText>Empty response</EmptyStateText>;
}
return (
<TextViewer
language={language}
<HttpTextViewer
response={response}
text={rawTextBody.data}
language={language}
pretty={pretty}
className={textViewerClassName}
response={response}
requestId={response.requestId}
/>
);
}
interface HttpTextViewerProps {
response: HttpResponse;
text: string;
language: EditorProps['language'];
pretty: boolean;
className?: string;
}
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
const [currentFilter, setCurrentFilter] = useState<string | null>(null);
const filteredBody = useResponseBodyText({ response, filter: currentFilter });
const filterCallback = useMemo(
() => (filter: string) => {
setCurrentFilter(filter);
return {
data: filteredBody.data,
isPending: filteredBody.isPending,
error: !!filteredBody.error,
};
},
[filteredBody],
);
return (
<TextViewer
text={text}
language={language}
stateKey={`response.body.${response.id}`}
pretty={pretty}
className={className}
onFilter={filterCallback}
/>
);
}

View File

@@ -1,21 +1,15 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { JsonAttributeTree } from '../core/JsonAttributeTree';
interface Props {
response: HttpResponse;
text: string;
className?: string;
}
export function JsonViewer({ response, className }: Props) {
const rawBody = useResponseBodyText({ response, filter: null });
if (rawBody.isLoading || rawBody.data == null) return null;
export function JsonViewer({ text, className }: Props) {
let parsed = {};
try {
parsed = JSON.parse(rawBody.data);
parsed = JSON.parse(text);
} catch {
// Nothing yet
}

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} />;
}

View File

@@ -3,7 +3,7 @@ import 'react-pdf/dist/Page/AnnotationLayer.css';
import { convertFileSrc } from '@tauri-apps/api/core';
import './PdfViewer.css';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Document, Page } from 'react-pdf';
import { useContainerSize } from '../../hooks/useContainerQuery';
@@ -15,7 +15,8 @@ import('react-pdf').then(({ pdfjs }) => {
});
interface Props {
bodyPath: string;
bodyPath?: string;
data?: Uint8Array;
}
const options = {
@@ -23,17 +24,29 @@ const options = {
standardFontDataUrl: '/standard_fonts/',
};
export function PdfViewer({ bodyPath }: Props) {
export function PdfViewer({ bodyPath, data }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number>();
const [src, setSrc] = useState<string | { data: Uint8Array }>();
const { width: containerWidth } = useContainerSize(containerRef);
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
// Create a copy to avoid "Buffer is already detached" errors
// This happens when the ArrayBuffer is transferred/detached elsewhere
const dataCopy = new Uint8Array(data);
setSrc({ data: dataCopy });
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);
};
const src = convertFileSrc(bodyPath);
return (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document

View File

@@ -1,30 +1,30 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useEffect, useState } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
interface Props {
response: HttpResponse;
text: string;
className?: string;
}
export function SvgViewer({ response }: Props) {
const rawTextBody = useResponseBodyText({ response, filter: null });
export function SvgViewer({ text, className }: Props) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
if (!rawTextBody.data) {
if (!text) {
return setSrc(null);
}
const blob = new Blob([rawTextBody.data], { type: 'image/svg+xml;charset=utf-8' });
const blob = new Blob([text], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [rawTextBody.data]);
}, [text]);
if (src == null) {
return null;
}
return <img src={src} alt="Response preview" className="max-w-full max-h-full pb-2" />;
return (
<img src={src} alt="Response preview" className={className ?? 'max-w-full max-h-full pb-2'} />
);
}

View File

@@ -1,11 +1,9 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import { createGlobalState } from 'react-use';
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useFormatText } from '../../hooks/useFormatText';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import type { EditorProps } from '../core/Editor/Editor';
import { hyperlink } from '../core/Editor/hyperlink/extension';
import { Editor } from '../core/Editor/LazyEditor';
@@ -15,29 +13,37 @@ import { Input } from '../core/Input';
const extraExtensions = [hyperlink];
interface Props {
pretty: boolean;
className?: string;
text: string;
language: EditorProps['language'];
response: HttpResponse;
requestId: string;
stateKey: string | null;
pretty?: boolean;
className?: string;
onFilter?: (filter: string) => {
data: string | null | undefined;
isPending: boolean;
error: boolean;
};
}
const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({ language, text, response, requestId, pretty, className }: Props) {
export function TextViewer({ language, text, stateKey, pretty, className, onFilter }: Props) {
const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = filterTextMap[requestId] ?? null;
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback(
(v: string | null) => {
setFilterTextMap((m) => ({ ...m, [requestId]: v }));
if (!stateKey) return;
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
},
[setFilterTextMap, requestId],
[setFilterTextMap, stateKey],
);
const isSearching = filterText != null;
const filteredResponse = useResponseBodyText({ response, filter: debouncedFilterText ?? null });
const filteredResponse =
onFilter && debouncedFilterText
? onFilter(debouncedFilterText)
: { data: null, isPending: false, error: false };
const toggleSearch = useCallback(() => {
if (isSearching) {
@@ -47,7 +53,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
}
}, [isSearching, setFilterText]);
const canFilter = language === 'json' || language === 'xml' || language === 'html';
const canFilter = onFilter && (language === 'json' || language === 'xml' || language === 'html');
const actions = useMemo<ReactNode[]>(() => {
const nodes: ReactNode[] = [];
@@ -58,7 +64,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
nodes.push(
<div key="input" className="w-full !opacity-100">
<Input
key={requestId}
key={stateKey ?? 'filter'}
validate={!filteredResponse.error}
hideLabel
autoFocus
@@ -70,7 +76,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
defaultValue={filterText}
onKeyDown={(e) => e.key === 'Escape' && toggleSearch()}
onChange={setFilterText}
stateKey={`filter.${response.id}`}
stateKey={stateKey ? `filter.${stateKey}` : null}
/>
</div>,
);
@@ -96,13 +102,12 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
filteredResponse.isPending,
isSearching,
language,
requestId,
response,
stateKey,
setFilterText,
toggleSearch,
]);
const formattedBody = useFormatText({ text, language, pretty });
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
if (formattedBody == null) {
return null;
}
@@ -132,8 +137,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
language={language}
actions={actions}
extraExtensions={extraExtensions}
// State key for storing fold state
stateKey={`response.body.${response.id}`}
stateKey={stateKey}
/>
);
}

View File

@@ -1,11 +1,26 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
interface Props {
bodyPath: string;
bodyPath?: string;
data?: Uint8Array;
}
export function VideoViewer({ bodyPath }: Props) {
const src = convertFileSrc(bodyPath);
export function VideoViewer({ bodyPath, data }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
const blob = new Blob([data], { type: 'video/mp4' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
// biome-ignore lint/a11y/useMediaCaption: none
return <video className="w-full" controls src={src} />;

View File

@@ -1,26 +1,22 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useMemo } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
interface Props {
response: HttpResponse;
html: string;
baseUrl?: string;
}
export function WebPageViewer({ response }: Props) {
const { url } = response;
const body = useResponseBodyText({ response, filter: null }).data ?? '';
export function WebPageViewer({ html, baseUrl }: Props) {
const contentForIframe: string | undefined = useMemo(() => {
if (body.includes('<head>')) {
return body.replace(/<head>/gi, `<head><base href="${url}"/>`);
if (baseUrl && html.includes('<head>')) {
return html.replace(/<head>/gi, `<head><base href="${baseUrl}"/>`);
}
return body;
}, [url, body]);
return html;
}, [baseUrl, html]);
return (
<div className="h-full pb-3">
<iframe
key={body ? 'has-body' : 'no-body'}
key={html ? 'has-body' : 'no-body'}
title="Yaak response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-forms"