Spaces:
Running
Running
| "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> | |
| ); | |
| }); | |