From 436a07c3c8e7d15a6c0fe346db3995a89788330e Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Fri, 20 Feb 2026 00:42:49 -0800 Subject: [PATCH] web: add HTML viewer with rendered preview and code modes --- .../app/api/workspace/browse-file/route.ts | 2 + apps/web/app/api/workspace/raw-file/route.ts | 2 + .../app/components/workspace/html-viewer.tsx | 276 ++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 apps/web/app/components/workspace/html-viewer.tsx diff --git a/apps/web/app/api/workspace/browse-file/route.ts b/apps/web/app/api/workspace/browse-file/route.ts index 3b5b3300ec5..eeed22d9857 100644 --- a/apps/web/app/api/workspace/browse-file/route.ts +++ b/apps/web/app/api/workspace/browse-file/route.ts @@ -18,6 +18,8 @@ const MIME_MAP: Record = { wav: "audio/wav", ogg: "audio/ogg", pdf: "application/pdf", + html: "text/html", + htm: "text/html", }; /** Extensions recognized as code files for syntax-highlighted viewing. */ diff --git a/apps/web/app/api/workspace/raw-file/route.ts b/apps/web/app/api/workspace/raw-file/route.ts index 82861ef8073..25c0084fe13 100644 --- a/apps/web/app/api/workspace/raw-file/route.ts +++ b/apps/web/app/api/workspace/raw-file/route.ts @@ -33,6 +33,8 @@ const MIME_MAP: Record = { m4a: "audio/mp4", // Documents pdf: "application/pdf", + html: "text/html", + htm: "text/html", }; /** diff --git a/apps/web/app/components/workspace/html-viewer.tsx b/apps/web/app/components/workspace/html-viewer.tsx new file mode 100644 index 00000000000..aebc7b21bfa --- /dev/null +++ b/apps/web/app/components/workspace/html-viewer.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { createHighlighter, type Highlighter } from "shiki"; + +type HtmlViewerProps = { + filename: string; + /** Raw URL for iframe rendering (served with text/html) */ + rawUrl: string; + /** JSON API URL to fetch source content on demand (for code view) */ + contentUrl: string; +}; + +type ViewMode = "rendered" | "code"; + +let highlighterPromise: Promise | null = null; + +function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: ["github-dark", "github-light"], + langs: ["html"], + }); + } + return highlighterPromise; +} + +export function HtmlViewer({ filename, rawUrl, contentUrl }: HtmlViewerProps) { + const [mode, setMode] = useState("rendered"); + const [source, setSource] = useState(null); + const [sourceLoading, setSourceLoading] = useState(false); + + const handleCodeToggle = useCallback(() => { + setMode("code"); + if (source !== null) {return;} + setSourceLoading(true); + void fetch(contentUrl) + .then((r) => r.json()) + .then((data: { content: string }) => setSource(data.content)) + .catch(() => setSource("")) + .finally(() => setSourceLoading(false)); + }, [contentUrl, source]); + + return ( +
+ {/* Header bar */} +
+ + + {filename} + + + HTML + + + {/* Mode toggle */} +
+ + +
+ + {/* Open in new tab */} + { + (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = "transparent"; + }} + > + + +
+ + {/* Content */} + {mode === "rendered" ? ( + + ) : sourceLoading || source === null ? ( +
+
+
+ ) : ( + + )} +
+ ); +} + +// --- Rendered HTML view (sandboxed iframe) --- + +function RenderedView({ rawUrl }: { rawUrl: string }) { + return ( +
+