flow / src /components /OutputView.tsx
mrfakename's picture
Initial Flow app
fb57ab3 verified
Raw History Blame Contribute Delete
4.21 kB
import { ArrowSquareOutIcon, FileIcon, HourglassIcon } from "@phosphor-icons/react";
import type { DataType, FileRef, OutputValue } from "../lib/types";
const isRef = (v: unknown): v is FileRef => !!v && typeof v === "object" && typeof (v as FileRef).url === "string";
const isImageUrl = (u: string) => /\.(png|jpe?g|webp|gif|avif|bmp|svg)(\?|$)/i.test(u);
const isAudioUrl = (u: string) => /\.(mp3|wav|ogg|flac|m4a|aac|opus)(\?|$)/i.test(u);
const isVideoUrl = (u: string) => /\.(mp4|webm|mov|mkv)(\?|$)/i.test(u);
const kindOf = (ref: FileRef, hint: DataType): "image" | "audio" | "video" | "file" => {
const m = ref.mime ?? "";
if (m.startsWith("image/") || isImageUrl(ref.url)) return "image";
if (m.startsWith("audio/") || isAudioUrl(ref.url)) return "audio";
if (m.startsWith("video/") || isVideoUrl(ref.url)) return "video";
if (hint === "image" || hint === "audio" || hint === "video") return hint;
return "file";
};
function FileView({ file, hint }: { file: FileRef; hint: DataType }) {
const kind = kindOf(file, hint);
const open = (
<a className="node-output__open nodrag" href={file.url} target="_blank" rel="noreferrer" title="Open in a new tab" onClick={(e) => e.stopPropagation()}>
<ArrowSquareOutIcon size={12} weight="bold" />
</a>
);
switch (kind) {
case "image":
return (
<>
<img src={file.url} alt={file.name ?? ""} draggable={false} />
{open}
</>
);
case "audio":
return (
<>
<audio src={file.url} controls className="nodrag" preload="metadata" />
{open}
</>
);
case "video":
return (
<>
<video src={file.url} controls className="nodrag" preload="metadata" />
{open}
</>
);
default:
return (
<a className="node-output__file nodrag" href={file.url} target="_blank" rel="noreferrer">
<FileIcon size={13} />
<span className="truncate">{file.name ?? file.url.split("/").pop()}</span>
</a>
);
}
}
/** Renders one output port's value. */
export function OutputView({ value, type, pending }: { value: OutputValue | undefined; type: DataType; pending?: boolean }) {
if (value === undefined || value === null || value === "") {
return (
<div className="node-output">
<div className="node-output__empty">
{pending ? <HourglassIcon size={12} /> : null}
<span>{pending ? "Waiting…" : "No output yet"}</span>
</div>
</div>
);
}
if (isRef(value)) {
return (
<div className="node-output">
<FileView file={value} hint={type} />
</div>
);
}
if (Array.isArray(value) && value.length && value.every(isRef)) {
const refs = value as FileRef[];
if (refs.length === 1 || (type === "image" && refs.every((r) => kindOf(r, type) === "image")))
return (
<div className="node-output">
<FileView file={refs[refs.length - 1]} hint={type} />
</div>
);
return (
<div className="node-output">
<div className="node-output__grid">
{refs.slice(0, 9).map((r, i) =>
kindOf(r, "image") === "image" ? (
<a key={i} href={r.url} target="_blank" rel="noreferrer" className="nodrag">
<img src={r.url} alt="" draggable={false} />
</a>
) : (
<FileView key={i} file={r} hint="file" />
),
)}
</div>
</div>
);
}
const text = typeof value === "string" ? value : typeof value === "number" || typeof value === "boolean" ? String(value) : JSON.stringify(value, null, 1);
return (
<div className="node-output">
<pre className="node-output__text nodrag nowheel">{text.length > 2000 ? `${text.slice(0, 2000)}…` : text}</pre>
</div>
);
}
/** First image URL in a value, for thumbnails. */
export const thumbOf = (value: OutputValue | undefined): string | null => {
if (isRef(value)) return kindOf(value, "image") === "image" ? value.url : null;
if (Array.isArray(value)) for (const v of value) if (isRef(v) && kindOf(v, "image") === "image") return v.url;
return null;
};