Commit 347bafba authored by Administrator's avatar Administrator

Create frontend/src/components/UIPreview.jsx via Son of Anton

parent 9442fe1d
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import {
X, Monitor, Tablet, Smartphone, Maximize2, RefreshCw,
Download, Copy, Check, Code2, Eye, Columns, ZoomIn, ZoomOut,
ExternalLink, RotateCcw, Sun, Moon, Paintbrush,
} from "lucide-react";
/* ═══════════════════════════════════════════════════
VIEWPORT PRESETS
═══════════════════════════════════════════════════ */
const VIEWPORTS = [
{ id: "desktop", label: "Desktop", width: 1440, height: 900, icon: Monitor },
{ id: "tablet", label: "Tablet", width: 768, height: 1024, icon: Tablet },
{ id: "mobile", label: "Mobile", width: 375, height: 812, icon: Smartphone },
{ id: "full", label: "Responsive", width: null, height: null, icon: Maximize2 },
];
const ZOOM_LEVELS = [25, 50, 75, 100, 125, 150, 200];
/* ═══════════════════════════════════════════════════
HTML BUILDER — Combines code blocks into a page
═══════════════════════════════════════════════════ */
const TAILWIND_RE = /class="[^"]*(?:flex|grid|p-\d|m-\d|bg-|text-(?:sm|lg|xl|\w+-\d)|rounded|shadow|border|w-|h-|gap-|space-|items-|justify-)/;
const REACT_IMPORT_RE = /(?:import\s+.*from\s+['"]react|React\.|useState|useEffect|useRef|jsx|<\w+[A-Z])/;
export function buildPreviewHTML(blocks) {
if (!blocks || !blocks.length) return null;
const htmlBlocks = blocks.filter(
(b) => b.language === "html" || b.filename?.match(/\.html?$/)
);
const cssBlocks = blocks.filter(
(b) => b.language === "css" || b.language === "scss" || b.filename?.match(/\.s?css$/)
);
const jsBlocks = blocks.filter(
(b) =>
["javascript", "js"].includes(b.language) ||
(b.filename?.match(/\.m?js$/) && !b.filename?.match(/\.jsx$/))
);
const reactBlocks = blocks.filter(
(b) =>
["jsx", "tsx", "react"].includes(b.language) ||
b.filename?.match(/\.(jsx|tsx)$/) ||
(["javascript", "js", "typescript", "ts"].includes(b.language) &&
REACT_IMPORT_RE.test(b.code))
);
const vueBlocks = blocks.filter(
(b) => b.language === "vue" || b.filename?.match(/\.vue$/)
);
const svelteBlocks = blocks.filter(
(b) => b.language === "svelte" || b.filename?.match(/\.svelte$/)
);
// ── Case 1: Complete HTML file ──
if (htmlBlocks.length) {
let html = htmlBlocks[0].code;
// Check if it's a full document or a fragment
const isFullDoc = /<!DOCTYPE|<html/i.test(html);
if (!isFullDoc) {
html = _wrapFragment(html, cssBlocks, jsBlocks);
} else {
// Inject additional CSS blocks
if (cssBlocks.length) {
const css = cssBlocks.map((b) => b.code).join("\n\n");
if (html.includes("</head>")) {
html = html.replace("</head>", `<style>\n${css}\n</style>\n</head>`);
} else {
html = `<style>\n${css}\n</style>\n` + html;
}
}
// Inject additional JS blocks
if (jsBlocks.length) {
const js = jsBlocks.map((b) => b.code).join("\n\n");
if (html.includes("</body>")) {
html = html.replace("</body>", `<script>\n${js}\n</script>\n</body>`);
} else {
html += `\n<script>\n${js}\n</script>`;
}
}
// Auto-inject Tailwind CDN if needed
html = _injectTailwindIfNeeded(html);
}
return html;
}
// ── Case 2: React/JSX ──
if (reactBlocks.length) {
return _buildReactPreview(reactBlocks, cssBlocks, jsBlocks);
}
// ── Case 3: Vue SFC ──
if (vueBlocks.length) {
return _buildVuePreview(vueBlocks, cssBlocks);
}
// ── Case 4: CSS only (showcase) ──
if (cssBlocks.length && !jsBlocks.length) {
return _buildCSSShowcase(cssBlocks);
}
// ── Case 5: JS only ──
if (jsBlocks.length) {
return _wrapFragment("", cssBlocks, jsBlocks);
}
return null;
}
function _wrapFragment(bodyHTML, cssBlocks, jsBlocks) {
const css = cssBlocks.map((b) => b.code).join("\n\n");
const js = jsBlocks.map((b) => b.code).join("\n\n");
const combined = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UI Preview</title>
${css ? `<style>\n${css}\n</style>` : ""}
</head>
<body>
${bodyHTML}
${js ? `<script>\n${js}\n</script>` : ""}
</body>
</html>`;
return _injectTailwindIfNeeded(combined);
}
function _buildReactPreview(reactBlocks, cssBlocks, jsBlocks) {
const css = cssBlocks.map((b) => b.code).join("\n\n");
const jsx = reactBlocks.map((b) => b.code).join("\n\n");
const js = jsBlocks.map((b) => b.code).join("\n\n");
// Strip import/export statements for browser execution
const cleanJSX = jsx
.replace(/^import\s+.*?from\s+['"][^'"]+['"];?\s*$/gm, "")
.replace(/^export\s+default\s+/gm, "const __DefaultExport__ = ")
.replace(/^export\s+/gm, "");
// Find the main component name
const componentMatch = cleanJSX.match(
/(?:function|const|class)\s+([A-Z]\w+)/
);
const mainComponent = componentMatch
? componentMatch[1]
: "__DefaultExport__";
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React Preview</title>
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin><\/script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin><\/script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"><\/script>
${css ? `<style>\n${css}\n</style>` : ""}
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
${js ? `<script>\n${js}\n<\/script>` : ""}
<script type="text/babel">
const { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext, useReducer } = React;
${cleanJSX}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(${mainComponent}));
<\/script>
</body>
</html>`;
return _injectTailwindIfNeeded(html);
}
function _buildVuePreview(vueBlocks, cssBlocks) {
const vue = vueBlocks[0].code;
const css = cssBlocks.map((b) => b.code).join("\n\n");
// Extract template, script, style from SFC
const templateMatch = vue.match(/<template>([\s\S]*?)<\/template>/);
const scriptMatch = vue.match(/<script[^>]*>([\s\S]*?)<\/script>/);
const styleMatch = vue.match(/<style[^>]*>([\s\S]*?)<\/style>/);
const template = templateMatch ? templateMatch[1] : "<div>Vue Component</div>";
const script = scriptMatch ? scriptMatch[1] : "";
const style = styleMatch ? styleMatch[1] : "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue Preview</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"><\/script>
<style>${css}\n${style}</style>
</head>
<body>
<div id="app">${template}</div>
<script>
${script.replace(/export\s+default\s*/, "const __comp__ = ")}
Vue.createApp(typeof __comp__ !== 'undefined' ? __comp__ : {}).mount('#app');
<\/script>
</body>
</html>`;
}
function _buildCSSShowcase(cssBlocks) {
const css = cssBlocks.map((b) => b.code).join("\n\n");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS Preview</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 2rem; }
${css}
</style>
</head>
<body>
<div class="preview-container">
<h1>CSS Preview</h1>
<p>Your styles are applied to this page. Add HTML elements to see them in action.</p>
</div>
</body>
</html>`;
}
function _injectTailwindIfNeeded(html) {
if (!TAILWIND_RE.test(html)) return html;
if (html.includes("tailwindcss") || html.includes("tailwind.")) return html;
const tailwindScript = '<script src="https://cdn.tailwindcss.com"><\/script>';
if (html.includes("</head>")) {
return html.replace("</head>", ` ${tailwindScript}\n</head>`);
}
return tailwindScript + "\n" + html;
}
export function isPreviewable(blocks) {
if (!blocks || !blocks.length) return false;
const langs = new Set(blocks.map((b) => b.language));
const files = blocks.map((b) => b.filename || "").join(" ");
if (langs.has("html") || files.match(/\.html?/)) return true;
if (langs.has("jsx") || langs.has("tsx") || files.match(/\.(jsx|tsx)/)) return true;
if (langs.has("vue") || files.match(/\.vue/)) return true;
if (langs.has("svelte") || files.match(/\.svelte/)) return true;
// CSS + JS combo
if (
(langs.has("css") || langs.has("scss")) &&
(langs.has("javascript") || langs.has("js"))
)
return true;
// Single HTML-like code that starts with tags
for (const b of blocks) {
if (b.code.trim().match(/^<!DOCTYPE|^<html|^<div|^<section|^<main|^<header/i))
return true;
}
return false;
}
/* ═══════════════════════════════════════════════════
MAIN COMPONENT
═══════════════════════════════════════════════════ */
export default function UIPreview({ html: initialHtml, title, onClose }) {
const [html, setHtml] = useState(initialHtml);
const [editHtml, setEditHtml] = useState(initialHtml);
const [viewport, setViewport] = useState("full");
const [viewMode, setViewMode] = useState("preview");
const [zoom, setZoom] = useState(100);
const [copied, setCopied] = useState(false);
const [blobUrl, setBlobUrl] = useState(null);
const [iframeKey, setIframeKey] = useState(0);
const [darkBg, setDarkBg] = useState(false);
const iframeRef = useRef(null);
const containerRef = useRef(null);
// Create blob URL for iframe
useEffect(() => {
if (!html) return;
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
setBlobUrl(url);
return () => URL.revokeObjectURL(url);
}, [html, iframeKey]);
// Escape key to close
useEffect(() => {
const onKey = (e) => {
if (e.key === "Escape") onClose?.();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleRefresh = useCallback(() => {
setIframeKey((k) => k + 1);
}, []);
const handleApplyCode = useCallback(() => {
setHtml(editHtml);
setIframeKey((k) => k + 1);
}, [editHtml]);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(html);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [html]);
const handleDownload = useCallback(() => {
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const safeName = (title || "design")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.slice(0, 50);
a.download = `${safeName || "design"}.html`;
a.click();
URL.revokeObjectURL(url);
}, [html, title]);
const handleOpenExternal = useCallback(() => {
if (!html) return;
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
// Don't revoke immediately — let the new tab load
setTimeout(() => URL.revokeObjectURL(url), 5000);
}, [html]);
const handleZoomIn = () =>
setZoom((z) => Math.min(z + 25, ZOOM_LEVELS[ZOOM_LEVELS.length - 1]));
const handleZoomOut = () => setZoom((z) => Math.max(z - 25, ZOOM_LEVELS[0]));
const currentVP = VIEWPORTS.find((v) => v.id === viewport);
const iframeWidth = currentVP?.width || "100%";
const iframeHeight = currentVP?.height || "100%";
const showPreview = viewMode === "preview" || viewMode === "split";
const showCode = viewMode === "code" || viewMode === "split";
return (
<div className="fixed inset-0 z-[100] bg-black/90 backdrop-blur-sm flex flex-col animate-fade-in">
{/* ═══ HEADER ═══ */}
<div className="flex items-center gap-2 px-3 py-2 bg-anton-surface border-b border-anton-border shrink-0 flex-wrap">
{/* Close + Title */}
<button
onClick={onClose}
className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
>
<X size={18} />
</button>
<div className="flex items-center gap-2 min-w-0 mr-2">
<Paintbrush size={16} className="text-anton-accent shrink-0" />
<span className="text-sm font-semibold text-white truncate max-w-[200px]">
{title || "UI Preview"}
</span>
</div>
{/* View Mode Tabs */}
<div className="flex items-center bg-anton-bg rounded-lg p-0.5 border border-anton-border">
{[
{ id: "preview", label: "Preview", icon: Eye },
{ id: "code", label: "Code", icon: Code2 },
{ id: "split", label: "Split", icon: Columns },
].map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setViewMode(id)}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition ${
viewMode === id
? "bg-anton-accent text-white shadow"
: "text-anton-muted hover:text-white"
}`}
>
<Icon size={13} />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
{/* Viewport Controls */}
<div className="flex items-center gap-0.5 ml-2">
{VIEWPORTS.map(({ id, label, icon: Icon, width }) => (
<button
key={id}
onClick={() => setViewport(id)}
className={`p-1.5 rounded-lg transition relative group ${
viewport === id
? "bg-anton-accent/20 text-anton-accent"
: "text-anton-muted hover:text-white hover:bg-anton-card"
}`}
title={`${label}${width ? ` (${width}px)` : ""}`}
>
<Icon size={15} />
</button>
))}
</div>
{/* Zoom */}
<div className="flex items-center gap-0.5 ml-2">
<button
onClick={handleZoomOut}
className="p-1 rounded text-anton-muted hover:text-white transition"
disabled={zoom <= ZOOM_LEVELS[0]}
>
<ZoomOut size={14} />
</button>
<span className="text-[10px] text-anton-muted font-mono w-8 text-center">
{zoom}%
</span>
<button
onClick={handleZoomIn}
className="p-1 rounded text-anton-muted hover:text-white transition"
disabled={zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]}
>
<ZoomIn size={14} />
</button>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Actions */}
<div className="flex items-center gap-1">
<button
onClick={() => setDarkBg(!darkBg)}
className={`p-1.5 rounded-lg transition ${
darkBg
? "bg-gray-700 text-yellow-300"
: "text-anton-muted hover:text-white hover:bg-anton-card"
}`}
title="Toggle background"
>
{darkBg ? <Moon size={14} /> : <Sun size={14} />}
</button>
<button
onClick={handleRefresh}
className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
title="Refresh preview"
>
<RefreshCw size={14} />
</button>
<button
onClick={handleOpenExternal}
className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
title="Open in new tab"
>
<ExternalLink size={14} />
</button>
<button
onClick={handleCopy}
className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
title="Copy HTML"
>
{copied ? (
<Check size={14} className="text-green-400" />
) : (
<Copy size={14} />
)}
</button>
<button
onClick={handleDownload}
className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
title="Download HTML"
>
<Download size={14} />
</button>
</div>
</div>
{/* ═══ VIEWPORT INDICATOR ═══ */}
{viewport !== "full" && (
<div className="text-center py-1 text-[10px] text-anton-muted bg-anton-bg/50 border-b border-anton-border shrink-0">
{currentVP?.label}{currentVP?.width}×{currentVP?.height}px
{zoom !== 100 && ` @ ${zoom}%`}
</div>
)}
{/* ═══ MAIN CONTENT ═══ */}
<div
ref={containerRef}
className="flex-1 flex overflow-hidden min-h-0"
>
{/* CODE PANEL */}
{showCode && (
<div
className={`flex flex-col bg-[#1a1b26] border-r border-anton-border overflow-hidden ${
viewMode === "split" ? "w-1/2" : "w-full"
}`}
>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-anton-border bg-anton-surface">
<span className="text-[10px] text-anton-accent font-mono uppercase">
HTML
</span>
<div className="flex gap-1">
<button
onClick={() => setEditHtml(initialHtml)}
className="text-[10px] text-anton-muted hover:text-white px-1.5 py-0.5 rounded transition flex items-center gap-1"
title="Reset to original"
>
<RotateCcw size={10} /> Reset
</button>
<button
onClick={handleApplyCode}
className="text-[10px] bg-anton-accent text-white px-2 py-0.5 rounded hover:opacity-80 transition"
>
Apply Changes
</button>
</div>
</div>
<textarea
value={editHtml}
onChange={(e) => setEditHtml(e.target.value)}
className="flex-1 w-full bg-transparent text-[12px] text-[#d4d4d4] font-mono p-3 resize-none focus:outline-none leading-relaxed"
spellCheck={false}
wrap="off"
/>
</div>
)}
{/* PREVIEW PANEL */}
{showPreview && (
<div
className={`flex-1 flex items-start justify-center overflow-auto ${
darkBg ? "bg-[#0a0a0a]" : "bg-[#f0f0f0]"
} ${viewMode === "split" ? "w-1/2" : "w-full"}`}
style={{ padding: viewport === "full" ? 0 : "24px" }}
>
<div
className="relative transition-all duration-300 ease-out"
style={{
width:
viewport === "full"
? "100%"
: `${(currentVP?.width || 1440) * (zoom / 100)}px`,
height: viewport === "full" ? "100%" : "auto",
maxWidth: "100%",
}}
>
{/* Device Frame */}
{viewport !== "full" && (
<div
className={`rounded-xl overflow-hidden shadow-2xl ${
darkBg ? "shadow-black/50" : "shadow-gray-400/30"
}`}
style={{
border: `${viewport === "mobile" ? 8 : viewport === "tablet" ? 6 : 2}px solid ${
darkBg ? "#222" : "#ccc"
}`,
borderRadius:
viewport === "mobile"
? "32px"
: viewport === "tablet"
? "20px"
: "8px",
}}
>
{/* Notch for mobile */}
{viewport === "mobile" && (
<div
className="flex justify-center py-1"
style={{
background: darkBg ? "#222" : "#ccc",
}}
>
<div
className="rounded-full"
style={{
width: "80px",
height: "6px",
background: darkBg ? "#333" : "#aaa",
}}
/>
</div>
)}
{blobUrl && (
<iframe
ref={iframeRef}
key={`frame-${iframeKey}`}
src={blobUrl}
sandbox="allow-scripts allow-popups allow-forms allow-modals"
style={{
width: `${currentVP.width}px`,
height: `${currentVP.height}px`,
transform: `scale(${zoom / 100})`,
transformOrigin: "top left",
border: "none",
display: "block",
background: "white",
}}
title="UI Preview"
/>
)}
</div>
)}
{/* Full responsive mode */}
{viewport === "full" && blobUrl && (
<iframe
ref={iframeRef}
key={`frame-${iframeKey}`}
src={blobUrl}
sandbox="allow-scripts allow-popups allow-forms allow-modals"
className="w-full border-none"
style={{
height: "100%",
minHeight: "calc(100vh - 120px)",
transform: zoom !== 100 ? `scale(${zoom / 100})` : undefined,
transformOrigin: "top left",
background: "white",
}}
title="UI Preview"
/>
)}
</div>
</div>
)}
</div>
{/* ═══ FOOTER STATUS BAR ═══ */}
<div className="flex items-center justify-between px-3 py-1.5 bg-anton-surface border-t border-anton-border text-[10px] text-anton-muted shrink-0">
<span>
{html.length.toLocaleString()} chars • Sandbox: scripts only
</span>
<span>
Son of Anton UI Preview •{" "}
<kbd className="px-1 py-0.5 bg-anton-bg rounded text-[9px]">Esc</kbd>{" "}
to close
</span>
</div>
</div>
);
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment