mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-13 19:30:29 +02:00
Split codebase (#455)
This commit is contained in:
27
apps/yaak-client/components/responseViewers/AudioViewer.tsx
Normal file
27
apps/yaak-client/components/responseViewers/AudioViewer.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
bodyPath?: string;
|
||||
data?: Uint8Array;
|
||||
}
|
||||
|
||||
export function AudioViewer({ bodyPath, data }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath) {
|
||||
setSrc(convertFileSrc(bodyPath));
|
||||
} else if (data) {
|
||||
const blob = new Blob([new Uint8Array(data)], { type: "audio/mpeg" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data]);
|
||||
|
||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <audio className="w-full" controls src={src} />;
|
||||
}
|
||||
37
apps/yaak-client/components/responseViewers/BinaryViewer.tsx
Normal file
37
apps/yaak-client/components/responseViewers/BinaryViewer.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { useSaveResponse } from "../../hooks/useSaveResponse";
|
||||
import { getContentTypeFromHeaders } from "../../lib/model_util";
|
||||
import { Button } from "../core/Button";
|
||||
import { Banner, InlineCode, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import { EmptyStateText } from "../EmptyStateText";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function BinaryViewer({ response }: Props) {
|
||||
const saveResponse = useSaveResponse(response);
|
||||
const contentType = getContentTypeFromHeaders(response.headers) ?? "unknown";
|
||||
|
||||
// Wait until the response has been fully-downloaded
|
||||
if (response.state !== "closed") {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon size="sm" />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner color="primary" className="h-full flex flex-col gap-3">
|
||||
<p>
|
||||
Content type <InlineCode>{contentType}</InlineCode> cannot be previewed
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="border" size="sm" onClick={() => saveResponse.mutate()}>
|
||||
Save to File
|
||||
</Button>
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
57
apps/yaak-client/components/responseViewers/CsvViewer.tsx
Normal file
57
apps/yaak-client/components/responseViewers/CsvViewer.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import classNames from "classnames";
|
||||
import Papa from "papaparse";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props {
|
||||
text: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CsvViewer({ text, className }: Props) {
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<CsvViewerInner text={text} className={className} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
||||
const parsed = useMemo(() => {
|
||||
if (text == null) return null;
|
||||
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
|
||||
}, [text]);
|
||||
|
||||
if (parsed === null) return null;
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<Table className={classNames(className, "text-sm")}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{parsed.meta.fields?.map((field) => (
|
||||
<TableHeaderCell key={field}>{field}</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{parsed.data.map((row, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableRow key={i}>
|
||||
{parsed.meta.fields?.map((key) => (
|
||||
<TableCell key={key}>{row[key] ?? ""}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import type { ServerSentEvent } from "@yaakapp-internal/sse";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useFormatText } from "../../hooks/useFormatText";
|
||||
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
|
||||
import { isJSON } from "../../lib/contentType";
|
||||
import { Button } from "../core/Button";
|
||||
import type { EditorProps } from "../core/Editor/Editor";
|
||||
import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { EventDetailHeader, EventViewer } from "../core/EventViewer";
|
||||
import { EventViewerRow } from "../core/EventViewerRow";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function EventStreamViewer({ response }: Props) {
|
||||
return (
|
||||
<Fragment
|
||||
key={response.id} // force a refresh when the response changes
|
||||
>
|
||||
<ActualEventStreamViewer response={response} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function ActualEventStreamViewer({ response }: Props) {
|
||||
const [showLarge, setShowLarge] = useState<boolean>(false);
|
||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||
const events = useResponseBodyEventSource(response);
|
||||
|
||||
return (
|
||||
<EventViewer
|
||||
events={events.data ?? []}
|
||||
getEventKey={(_, index) => String(index)}
|
||||
error={events.error ? String(events.error) : null}
|
||||
splitLayoutStorageKey="sse_events"
|
||||
defaultRatio={0.4}
|
||||
renderRow={({ event, index, isActive, onClick }) => (
|
||||
<EventViewerRow
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
|
||||
content={
|
||||
<HStack space={2} className="items-center">
|
||||
<EventLabels event={event} index={index} isActive={isActive} />
|
||||
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
renderDetail={({ event, index, onClose }) => (
|
||||
<EventDetail
|
||||
event={event}
|
||||
index={index}
|
||||
showLarge={showLarge}
|
||||
showingLarge={showingLarge}
|
||||
setShowLarge={setShowLarge}
|
||||
setShowingLarge={setShowingLarge}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EventDetail({
|
||||
event,
|
||||
index,
|
||||
showLarge,
|
||||
showingLarge,
|
||||
setShowLarge,
|
||||
setShowingLarge,
|
||||
onClose,
|
||||
}: {
|
||||
event: ServerSentEvent;
|
||||
index: number;
|
||||
showLarge: boolean;
|
||||
showingLarge: boolean;
|
||||
setShowLarge: (v: boolean) => void;
|
||||
setShowingLarge: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const language = useMemo<"text" | "json">(() => {
|
||||
if (!event?.data) return "text";
|
||||
return isJSON(event?.data) ? "json" : "text";
|
||||
}, [event?.data]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<EventDetailHeader
|
||||
title="Message Received"
|
||||
prefix={<EventLabels event={event} index={index} />}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{!showLarge && event.data.length > 1000 * 1000 ? (
|
||||
<VStack space={2} className="italic text-text-subtlest">
|
||||
Message previews larger than 1MB are hidden
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowingLarge(true);
|
||||
setTimeout(() => {
|
||||
setShowLarge(true);
|
||||
setShowingLarge(false);
|
||||
}, 500);
|
||||
}}
|
||||
isLoading={showingLarge}
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
>
|
||||
Try Showing
|
||||
</Button>
|
||||
</div>
|
||||
</VStack>
|
||||
) : (
|
||||
<FormattedEditor language={language} text={event.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormattedEditor({ text, language }: { text: string; language: EditorProps["language"] }) {
|
||||
const formatted = useFormatText({ text, language, pretty: true });
|
||||
if (formatted == null) return null;
|
||||
return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;
|
||||
}
|
||||
|
||||
function EventLabels({
|
||||
className,
|
||||
event,
|
||||
index,
|
||||
isActive,
|
||||
}: {
|
||||
event: ServerSentEvent;
|
||||
index: number;
|
||||
className?: string;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<HStack space={1.5} alignItems="center" className={className}>
|
||||
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}>
|
||||
{event.id ?? index}
|
||||
</InlineCode>
|
||||
{event.eventType && (
|
||||
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}>
|
||||
{event.eventType}
|
||||
</InlineCode>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
pretty: boolean;
|
||||
textViewerClassName?: string;
|
||||
}
|
||||
|
||||
export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {
|
||||
const rawTextBody = useResponseBodyText({ response, filter: null });
|
||||
const contentType = getContentTypeFromHeaders(response.headers);
|
||||
const language = languageFromContentType(contentType, rawTextBody.data ?? "");
|
||||
|
||||
if (rawTextBody.isLoading || response.state === "initialized") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (language === "html" && pretty) {
|
||||
return <WebPageViewer html={rawTextBody.data ?? ""} baseUrl={response.url} />;
|
||||
}
|
||||
if (rawTextBody.data == null) {
|
||||
return <EmptyStateText>Empty response</EmptyStateText>;
|
||||
}
|
||||
return (
|
||||
<HttpTextViewer
|
||||
response={response}
|
||||
text={rawTextBody.data}
|
||||
language={language}
|
||||
pretty={pretty}
|
||||
className={textViewerClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
apps/yaak-client/components/responseViewers/ImageViewer.tsx
Normal file
39
apps/yaak-client/components/responseViewers/ImageViewer.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = { className?: string } & (
|
||||
| {
|
||||
bodyPath: string;
|
||||
}
|
||||
| {
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
);
|
||||
|
||||
export function ImageViewer({ className, ...props }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
|
||||
const data = "data" in props ? props.data : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath != null) {
|
||||
setSrc(convertFileSrc(bodyPath));
|
||||
} else if (data != null) {
|
||||
const blob = new Blob([data], { type: "image/png" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data]);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="Response preview"
|
||||
className={classNames(className, "max-w-full max-h-full")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
apps/yaak-client/components/responseViewers/JsonViewer.tsx
Normal file
22
apps/yaak-client/components/responseViewers/JsonViewer.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import classNames from "classnames";
|
||||
import { JsonAttributeTree } from "../core/JsonAttributeTree";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function JsonViewer({ text, className }: Props) {
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
// Nothing yet
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(className, "overflow-x-auto h-full")}>
|
||||
<JsonAttributeTree attrValue={parsed} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
apps/yaak-client/components/responseViewers/MultipartViewer.tsx
Normal file
136
apps/yaak-client/components/responseViewers/MultipartViewer.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { type MultipartPart, parseMultipart } from "@mjackson/multipart-parser";
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { languageFromContentType } from "../../lib/contentType";
|
||||
import { Banner, Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import { TabContent, Tabs } from "../core/Tabs/Tabs";
|
||||
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 {
|
||||
data: Uint8Array;
|
||||
boundary: string;
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
export function MultipartViewer({ data, boundary, idPrefix = "multipart" }: Props) {
|
||||
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 { parts, error } = parseResult;
|
||||
|
||||
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
|
||||
addBorders
|
||||
label="Multipart"
|
||||
layout="horizontal"
|
||||
tabListClassName="border-r border-r-border -ml-3"
|
||||
tabs={parts.map((part, i) => ({
|
||||
label: part.name ?? "",
|
||||
value: tabValue(part, i),
|
||||
rightSlot:
|
||||
part.filename && part.headers.contentType.mediaType?.startsWith("image/") ? (
|
||||
<div className="h-5 w-5 overflow-auto flex items-center justify-end">
|
||||
<ImageViewer
|
||||
data={part.arrayBuffer}
|
||||
className="ml-auto w-auto rounded overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
) : part.filename ? (
|
||||
<Icon icon="file" />
|
||||
) : null,
|
||||
}))}
|
||||
>
|
||||
{parts.map((part, i) => (
|
||||
<TabContent
|
||||
// oxlint-disable-next-line react/no-array-index-key -- Nothing else to key on
|
||||
key={idPrefix + part.name + i}
|
||||
value={tabValue(part, i)}
|
||||
className="pl-3 !pt-0"
|
||||
>
|
||||
<Part part={part} />
|
||||
</TabContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function Part({ part }: { part: MultipartPart }) {
|
||||
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(/^audio/i)) {
|
||||
return <AudioViewer data={uint8Array} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^video/i)) {
|
||||
return <VideoViewer data={uint8Array} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/csv|tab-separated/i)) {
|
||||
return <CsvViewer text={content} className="bg-primary h-10 w-10" />;
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
function tabValue(part: MultipartPart, i: number) {
|
||||
return `${part.name ?? ""}::${i}`;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.react-pdf__Document * {
|
||||
user-select: text;
|
||||
}
|
||||
75
apps/yaak-client/components/responseViewers/PdfViewer.tsx
Normal file
75
apps/yaak-client/components/responseViewers/PdfViewer.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import "react-pdf/dist/Page/TextLayer.css";
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import "./PdfViewer.css";
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Document, Page } from "react-pdf";
|
||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||
import { fireAndForget } from "../../lib/fireAndForget";
|
||||
|
||||
fireAndForget(
|
||||
import("react-pdf").then(({ pdfjs }) => {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
}),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
bodyPath?: string;
|
||||
data?: Uint8Array;
|
||||
}
|
||||
|
||||
const options = {
|
||||
cMapUrl: "/cmaps/",
|
||||
standardFontDataUrl: "/standard_fonts/",
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full overflow-y-auto">
|
||||
<Document
|
||||
file={src}
|
||||
options={options}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
externalLinkTarget="_blank"
|
||||
externalLinkRel="noopener noreferrer"
|
||||
>
|
||||
{Array.from({ length: numPages ?? 0 }, (_, index) => (
|
||||
<Page
|
||||
className="mb-6 select-all"
|
||||
renderTextLayer
|
||||
renderAnnotationLayer
|
||||
key={`page_${index + 1}`}
|
||||
pageNumber={index + 1}
|
||||
width={containerWidth}
|
||||
/>
|
||||
))}
|
||||
</Document>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/yaak-client/components/responseViewers/SvgViewer.tsx
Normal file
30
apps/yaak-client/components/responseViewers/SvgViewer.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SvgViewer({ text, className }: Props) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!text) {
|
||||
return setSrc(null);
|
||||
}
|
||||
|
||||
const blob = new Blob([text], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [text]);
|
||||
|
||||
if (src == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img src={src} alt="Response preview" className={className ?? "max-w-full max-h-full pb-2"} />
|
||||
);
|
||||
}
|
||||
151
apps/yaak-client/components/responseViewers/TextViewer.tsx
Normal file
151
apps/yaak-client/components/responseViewers/TextViewer.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { createGlobalState } from "react-use";
|
||||
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
||||
import { useFormatText } from "../../hooks/useFormatText";
|
||||
import type { EditorProps } from "../core/Editor/Editor";
|
||||
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
||||
import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Input } from "../core/Input";
|
||||
|
||||
const extraExtensions = [hyperlink];
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
language: EditorProps["language"];
|
||||
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, stateKey, pretty, className, onFilter }: Props) {
|
||||
const [filterTextMap, setFilterTextMap] = useFilterText();
|
||||
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
|
||||
const debouncedFilterText = useDebouncedValue(filterText);
|
||||
const setFilterText = useCallback(
|
||||
(v: string | null) => {
|
||||
if (!stateKey) return;
|
||||
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
|
||||
},
|
||||
[setFilterTextMap, stateKey],
|
||||
);
|
||||
|
||||
const isSearching = filterText != null;
|
||||
const filteredResponse =
|
||||
onFilter && debouncedFilterText
|
||||
? onFilter(debouncedFilterText)
|
||||
: { data: null, isPending: false, error: false };
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
if (isSearching) {
|
||||
setFilterText(null);
|
||||
} else {
|
||||
setFilterText("");
|
||||
}
|
||||
}, [isSearching, setFilterText]);
|
||||
|
||||
const canFilter = onFilter && (language === "json" || language === "xml" || language === "html");
|
||||
|
||||
const actions = useMemo<ReactNode[]>(() => {
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (!canFilter) return nodes;
|
||||
|
||||
if (isSearching) {
|
||||
nodes.push(
|
||||
<div key="input" className="w-full !opacity-100">
|
||||
<Input
|
||||
key={stateKey ?? "filter"}
|
||||
validate={!filteredResponse.error}
|
||||
hideLabel
|
||||
autoFocus
|
||||
containerClassName="bg-surface"
|
||||
size="sm"
|
||||
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
||||
label="Filter expression"
|
||||
name="filter"
|
||||
defaultValue={filterText}
|
||||
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
|
||||
onChange={setFilterText}
|
||||
stateKey={stateKey ? `filter.${stateKey}` : null}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<IconButton
|
||||
key="icon"
|
||||
size="sm"
|
||||
isLoading={filteredResponse.isPending}
|
||||
icon={isSearching ? "x" : "filter"}
|
||||
title={isSearching ? "Close filter" : "Filter response"}
|
||||
onClick={toggleSearch}
|
||||
className={classNames("border !border-border-subtle", isSearching && "!opacity-100")}
|
||||
/>,
|
||||
);
|
||||
|
||||
return nodes;
|
||||
}, [
|
||||
canFilter,
|
||||
filterText,
|
||||
filteredResponse.error,
|
||||
filteredResponse.isPending,
|
||||
isSearching,
|
||||
language,
|
||||
stateKey,
|
||||
setFilterText,
|
||||
toggleSearch,
|
||||
]);
|
||||
|
||||
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
||||
if (formattedBody == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let body: string;
|
||||
if (isSearching && filterText?.length > 0) {
|
||||
if (filteredResponse.error) {
|
||||
body = "";
|
||||
} else {
|
||||
body = filteredResponse.data != null ? filteredResponse.data : "";
|
||||
}
|
||||
} else {
|
||||
body = formattedBody;
|
||||
}
|
||||
|
||||
// Decode unicode sequences in the text to readable characters
|
||||
if (language === "json" && pretty) {
|
||||
body = decodeUnicodeLiterals(body);
|
||||
body = body.replace(/\\\//g, "/"); // Hide unnecessary escaping of '/' by some older frameworks
|
||||
}
|
||||
|
||||
return (
|
||||
<Editor
|
||||
readOnly
|
||||
className={className}
|
||||
defaultValue={body}
|
||||
language={language}
|
||||
actions={actions}
|
||||
extraExtensions={extraExtensions}
|
||||
stateKey={stateKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert \uXXXX to actual Unicode characters */
|
||||
function decodeUnicodeLiterals(text: string): string {
|
||||
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
||||
const charCode = Number.parseInt(hex, 16);
|
||||
return String.fromCharCode(charCode);
|
||||
});
|
||||
}
|
||||
27
apps/yaak-client/components/responseViewers/VideoViewer.tsx
Normal file
27
apps/yaak-client/components/responseViewers/VideoViewer.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
bodyPath?: string;
|
||||
data?: Uint8Array;
|
||||
}
|
||||
|
||||
export function VideoViewer({ bodyPath, data }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath) {
|
||||
setSrc(convertFileSrc(bodyPath));
|
||||
} else if (data) {
|
||||
const blob = new Blob([new Uint8Array(data)], { type: "video/mp4" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data]);
|
||||
|
||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <video className="w-full" controls src={src} />;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Props {
|
||||
html: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export function WebPageViewer({ html, baseUrl }: Props) {
|
||||
const contentForIframe: string | undefined = useMemo(() => {
|
||||
if (baseUrl && html.includes("<head>")) {
|
||||
return html.replace(/<head>/gi, `<head><base href="${baseUrl}"/>`);
|
||||
}
|
||||
return html;
|
||||
}, [baseUrl, html]);
|
||||
|
||||
return (
|
||||
<div className="h-full pb-3">
|
||||
<iframe
|
||||
key={html ? "has-body" : "no-body"}
|
||||
title="Yaak response preview"
|
||||
srcDoc={contentForIframe}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full rounded-lg border border-border-subtle"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user