viktor-hu's picture
Update UI (#8)
25c879d verified
"use client";
import {
useState,
forwardRef,
useImperativeHandle,
useEffect,
useRef,
} from "react";
import { DEFAULT_HTML } from "@/lib/constants";
import { PreviewRef } from "@/lib/types";
import {
MinimizeIcon,
MaximizeIcon,
DownloadIcon,
RefreshIcon,
} from "./ui/icons";
import { useModel } from "@/lib/contexts/model-context";
import { Loader2, Share2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { generateShareLink } from "@/lib/sharelink";
import { ShareDialog } from "./share-dialog";
import posthog from "posthog-js";
interface PreviewProps {
initialHtml?: string;
onCodeChange?: (html: string, save?: boolean) => void;
onAuthErrorChange?: (show: boolean) => void;
onLoadingChange?: (loading: boolean) => void;
onErrorChange?: (error: string | null) => void;
currentVersion?: string;
}
export const Preview = forwardRef<PreviewRef, PreviewProps>(function Preview(
{
initialHtml,
onCodeChange,
onAuthErrorChange,
onLoadingChange,
onErrorChange,
currentVersion,
},
ref,
) {
const [html, setHtml] = useState<string>(initialHtml || "");
const [isFullscreen, setIsFullscreen] = useState(false);
const [loading, setLoading] = useState(false);
const [isPartialGenerating, setIsPartialGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAuthError, setShowAuthError] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [isSharing, setIsSharing] = useState(false);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [shareUrl, setShareUrl] = useState<string>("");
const { selectedModelId } = useModel();
const renderCount = useRef(0);
const headUpdated = useRef(false);
// Update html when initialHtml changes
useEffect(() => {
if (initialHtml && !isPartialGenerating) {
setHtml(initialHtml);
}
}, [initialHtml, isPartialGenerating]);
// Update parent component when error changes
useEffect(() => {
if (onErrorChange) {
onErrorChange(error);
}
}, [error, onErrorChange]);
useImperativeHandle(ref, () => ({
generateCode: async (
prompt: string,
colors: string[] = [],
previousPrompt?: string,
) => {
await generateCode(prompt, colors, previousPrompt);
},
}));
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
const partialUpdate = (htmlStr: string) => {
const parser = new DOMParser();
const partialDoc = parser.parseFromString(htmlStr, "text/html");
const iframe = document.querySelector("iframe");
if (!iframe || !iframe.contentDocument) return;
const iframeContainer = iframe.contentDocument;
if (iframeContainer?.body && iframeContainer) {
iframeContainer.body.innerHTML = partialDoc.body?.innerHTML;
}
if (renderCount.current % 10 === 0 && !headUpdated.current) {
setHtml(htmlStr);
if (htmlStr.includes("</head>")) {
setTimeout(() => {
headUpdated.current = true;
}, 1000);
}
}
renderCount.current++;
};
const downloadHtml = () => {
if (!html) return;
// Get current version and generate filename
// If we have a currentVersion, use it; otherwise omit version part
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.replace("T", "_")
.slice(0, 19);
// Load current version from localStorage if we have an ID
let versionLabel = "";
if (currentVersion) {
versionLabel = `-${currentVersion}`;
}
// Format the filename with or without version
const filename = `novita-anysite-generated${versionLabel}-${timestamp}.html`;
const blob = new Blob([html], { type: "text/html" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
};
const refreshPreview = () => {
if (!html) return;
setRefreshKey((prev) => prev + 1);
};
const generateCode = async (
prompt: string,
colors: string[] = [],
previousPrompt?: string,
) => {
setLoading(true);
renderCount.current = 0;
headUpdated.current = false;
if (onLoadingChange) {
onLoadingChange(true);
}
setError(null);
setShowAuthError(false);
if (onAuthErrorChange) {
onAuthErrorChange(false);
}
// Clear HTML content when generation starts
setHtml("");
// Initialize generated code variable at function scope so it's accessible in finally block
let generatedCode = "";
try {
// Only include html in the request if it's not DEFAULT_HTML
const isDefaultHtml = initialHtml === DEFAULT_HTML;
posthog.capture("Generate code", { model: selectedModelId });
const response = await fetch("/api/generate-code", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt,
html: isDefaultHtml ? undefined : html,
previousPrompt: isDefaultHtml ? undefined : previousPrompt,
colors,
modelId: selectedModelId,
}),
});
if (!response.ok) {
posthog.capture("Generate code", {
type: "failed",
model: selectedModelId,
status: response.status,
});
// Check specifically for 401 error (authentication required)
if (response.status === 401 || response.status === 403) {
try {
const errorData = await response.json();
if (errorData.openLogin) {
setShowAuthError(true);
if (onAuthErrorChange) {
onAuthErrorChange(true);
}
throw new Error("Signing in to Hugging Face is required.");
}
} catch (e) {
// Fall back to default auth error handling if JSON parsing fails
setShowAuthError(true);
if (onAuthErrorChange) {
onAuthErrorChange(true);
}
throw new Error("Signing in to Hugging Face is required.");
}
}
const errorData = await response.json();
throw new Error(errorData.message || "Failed to generate code");
}
// Handle streaming response
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let lastRenderTime = 0;
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) {
if (!generatedCode.includes("</html>")) {
generatedCode += "</html>";
}
const finalCode = generatedCode.match(
/<!DOCTYPE html>[\s\S]*<\/html>/,
)?.[0];
if (finalCode) {
// Update state with the final code
setHtml(finalCode);
// Only call onCodeChange once with the final code
// Add a small delay to ensure all state updates have been applied
if (onCodeChange) {
setTimeout(() => {
onCodeChange(finalCode, true);
}, 50);
}
}
setIsPartialGenerating(false);
break;
} else {
setIsPartialGenerating(true);
}
const chunkText = decoder.decode(value, { stream: true });
let parsedChunk: any;
let appended = false;
try {
// Try to parse as JSON
parsedChunk = JSON.parse(chunkText);
} catch (parseError) {
appended = true;
// If JSON parsing fails, treat it as plain text (backwards compatibility)
generatedCode += chunkText;
}
if (parsedChunk && parsedChunk.type === "error") {
throw new Error(parsedChunk.message || "An error occurred");
} else if (!appended) {
generatedCode += chunkText;
}
const newCode = generatedCode.match(/<!DOCTYPE html>[\s\S]*/)?.[0];
if (newCode) {
// Force-close the HTML tag so the iframe doesn't render half-finished markup
let partialDoc = newCode;
if (!partialDoc.endsWith("</html>")) {
partialDoc += "\n</html>";
}
// Throttle the re-renders to avoid flashing/flicker
const now = Date.now();
if (now - lastRenderTime > 200) {
// Update the UI with partial code, but don't call onCodeChange
partialUpdate(partialDoc);
if (onCodeChange) {
onCodeChange(partialDoc, false);
}
lastRenderTime = now;
}
}
}
}
} catch (err) {
const errorMessage =
(err as Error).message || "An error occurred while generating code";
posthog.capture("Generate code", {
type: "failed",
model: selectedModelId,
error: errorMessage,
});
setError(errorMessage);
if (onErrorChange) {
onErrorChange(errorMessage);
}
console.error("Error generating code:", err);
} finally {
setLoading(false);
if (onLoadingChange) {
onLoadingChange(false);
}
}
};
const handleShare = async () => {
if (!html) {
setError("No HTML content to share");
return;
}
setIsSharing(true);
setError(null);
try {
const uploadedUrl = await generateShareLink(html);
setShareUrl(uploadedUrl);
setShareDialogOpen(true);
} catch (err) {
const errorMessage = (err as Error).message || "Failed to share HTML";
setError(errorMessage);
if (onErrorChange) {
onErrorChange(errorMessage);
}
console.error("Error sharing HTML:", err);
} finally {
setIsSharing(false);
}
};
const handleShareDialogClose = (open: boolean) => {
setShareDialogOpen(open);
if (!open) {
setShareUrl("");
}
};
return (
<div
className={`${isFullscreen ? "fixed inset-0 z-10 bg-novita-dark" : "h-full"} p-4 pl-2`}
>
{isPartialGenerating && (
<div className="w-full bg-slate-50 border-b border-slate-200 py-2 px-4">
<div className="container mx-auto flex items-center justify-center">
<div className="flex items-center space-x-2 text-slate-700">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm font-medium">building...</span>
</div>
</div>
</div>
)}
<div className="bg-white text-black h-full overflow-hidden relative isolation-auto">
<div className="absolute top-3 right-3 flex gap-2 z-[100]">
<button
onClick={handleShare}
disabled={isSharing || !html}
className="bg-novita-gray/90 text-white p-2 px-3 text-xs rounded-md shadow-md hover:bg-novita-gray/70 transition-colors flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Share Link"
title="Share Link"
>
{isSharing ? (
<Loader2 className="h-3 w-3 mr-2 animate-spin" />
) : (
<Share2 className="h-3 w-3 mr-2" />
)}
{isSharing ? "Sharing..." : "Share Link"}
</button>
<button
onClick={refreshPreview}
className="bg-novita-gray/90 text-white p-2 rounded-md shadow-md hover:bg-novita-gray/70 transition-colors flex items-center justify-center"
aria-label="Refresh Preview"
title="Refresh Preview"
>
<RefreshIcon />
</button>
<button
onClick={downloadHtml}
className="bg-novita-gray/90 text-white p-2 rounded-md shadow-md hover:bg-novita-gray/70 transition-colors flex items-center justify-center"
aria-label="Download HTML"
title="Download HTML"
>
<DownloadIcon />
</button>
<button
onClick={toggleFullscreen}
className="bg-novita-gray/90 text-white p-2 rounded-md shadow-md hover:bg-novita-gray/70 transition-colors flex items-center justify-center"
aria-label={isFullscreen ? "Exit Fullscreen" : "Full Screen"}
title={isFullscreen ? "Exit Fullscreen" : "Full Screen"}
>
{isFullscreen ? <MinimizeIcon /> : <MaximizeIcon />}
</button>
</div>
<iframe
key={refreshKey}
className={cn("relative z-10 w-full h-full select-none", {
"pointer-events-none": loading,
})}
srcDoc={html}
title="Preview"
/>
</div>
<ShareDialog
open={shareDialogOpen}
onOpenChange={handleShareDialogClose}
shareUrl={shareUrl}
/>
</div>
);
});