From 3c8bb264cedcc8a740b849b11e6fb0ff6fe35b6d Mon Sep 17 00:00:00 2001 From: Swathi Chippa Date: Mon, 25 May 2026 08:41:32 +0530 Subject: [PATCH] feat: display JS runtime errors in live preview panel --- app/page.tsx | 1756 +++++++++++++++++++++----------------------------- 1 file changed, 727 insertions(+), 1029 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index d3c5c18..72355f1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,4 @@ -"use client" +"use client" const safeBase64Encode = (str: string) => btoa(unescape(encodeURIComponent(str))); @@ -7,29 +7,24 @@ const safeBase64Decode = (str: string) => decodeURIComponent(escape(atob(str))); import type React from "react" -import { useState, useEffect, useRef } from "react" +import { useState, useEffect, useMemo, useRef, useCallback } from "react" import { Button } from "@/components/ui/button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" - +import { Badge } from "@/components/ui/badge" +import { CopyButton } from "@/components/ui/copy-button" +import { CommandPalette, type Command } from "@/components/ui/command-palette" import { - Code2, - Play, - Download, - Layout, - FileText, - Palette, - Zap, - Sun, - Moon, - Link as LinkIcon, - Timer, + Code2, Play, Download, Upload, Layout, Maximize2, Minimize2, + FileText, Palette, Zap, Sun, Moon, Search, Link as LinkIcon, + Undo2, Redo2, Timer, MoreHorizontal, X, } from "lucide-react" import { toast } from "sonner" - - - - +import * as prettier from 'prettier' +import parserHtml from 'prettier/plugins/html' +import parserCss from 'prettier/plugins/postcss' +import parserBabel from 'prettier/plugins/babel' +import parserEstree from 'prettier/plugins/estree' import JSZip from "jszip" import dynamic from "next/dynamic" import Link from "next/link" @@ -38,6 +33,7 @@ import { PreviewErrorBoundary, AppErrorBoundary, } from "./components/error-boundary" +import AIAssistant from "./components/AIAssistant" const MonacoEditor = dynamic(() => import("./components/monaco-editor"), { ssr: false, @@ -120,929 +116,9 @@ const templates: Template[] = [ description: "Modern landing page template", icon: , content: { - - html: ` - - - - - Modern Landing Page - - -
- -
- -
-
-

Welcome to the Future

-

Build amazing things with our platform

- -
-
- -
-
-

Amazing Features

-

Everything you need to build high-performance applications with ease

-
-
-
- -
-

Lightning Fast

-

Experience blazing-fast render times and optimized resource delivery for peak performance.

-
-
-
- -
-

Secure by Design

-

Your data is protected with end-to-end encryption, strict compliance, and active threat monitoring.

-
-
-
- -
-

Advanced Analytics

-

Gain deeper insights into user engagement, system health, and growth metrics in real-time.

-
-
-
-
- -
-
-
-

About Our Platform

-

We are dedicated to building a platform that empowers developers and creators. By focusing on cutting-edge technologies, we eliminate complex configurations so you can focus purely on what matters: your code.

-

Our platform handles scaling, global CDN edge caching, and automated builds, allowing you to deploy dynamic, beautiful web applications with just one click.

-
-
- - Collaborative developer workflows -
-
- - Automatic scaling & edge routing -
-
- - Integrated analytics and logging -
-
-
- -
-
- -
-
-

What Our Users Say

-

Join thousands of developers and teams already building the future on our platform

-
-
-
★★★★★
-

"Brand has completely transformed our workflow. The setup was instant, and the interface is incredibly smooth. Deploying landing pages takes seconds now!"

- -
-
-
★★★★★
-

"The performance boost we saw after migrating to this platform was unbelievable. Plus, the built-in analytics are actually useful rather than bloated."

- -
-
-
★★★★★
-

"Support is responsive, the documentation is clear, and the developer experience is unmatched. I can't recommend this platform enough."

- -
-
-
-
- - - -`, - css: `* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - line-height: 1.6; -} - -.header { - color: white; - background: #130a2e; - padding: 1rem 0; - position: fixed; - width: 100%; - top: 0; - z-index: 1000; -} - -.nav { - display: flex; - justify-content: space-between; - align-items: center; - max-width: 1200px; - margin: 0 auto; - padding: 0 2rem; -} - -.logo { - font-size: 1.5rem; - font-weight: bold; -} - -.nav-links { - display: flex; - list-style: none; - gap: 2rem; -} - -.nav-links a { - color: white; - text-decoration: none; - transition: opacity 0.3s; -} - -.nav-links a:hover { - opacity: 0.8; -} - -.hero { - height: 100vh; - background-color: #130a2e; - background-image: - radial-gradient(circle at 15% 50%, rgba(102, 126, 234, 0.15), transparent 25%), - radial-gradient(circle at 85% 30%, rgba(118, 75, 162, 0.15), transparent 25%); - display: flex; - align-items: center; - justify-content: center; - text-align: center; - color: white; - position: relative; - overflow: hidden; -} - -.hero-content { - max-width: 600px; - padding: 2rem; - position: relative; - z-index: 1; -} - -.hero-title { - font-size: 4rem; - font-weight: 800; - margin-bottom: 1.25rem; - background: linear-gradient(to right, #ffffff, #c5bedb); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - animation: fadeInUp 1s ease-out; -} - -.hero-subtitle { - font-size: 1.25rem; - color: #c5bedb; - margin-bottom: 2.5rem; - animation: fadeInUp 1s ease-out 0.2s both; -} - -.cta-button { - color: #130a2e; - font-weight: 700; - border: none; - padding: 1rem 2.5rem; - font-size: 1.1rem; - border-radius: 50px; - cursor: pointer; - transition: all 0.3s ease; - animation: fadeInUp 1s ease-out 0.4s both; -} - -.cta-button:hover { - transform: translateY(-3px) scale(1.02); - box-shadow: 0 15px 25px -5px rgba(102, 126, 234, 0.6); -} - -.section { - padding: 6rem 2rem; - background: #130a2e; - color: #ffffff; - display: flex; - justify-content: center; - align-items: center; - scroll-margin-top: 70px; -} - -.container { - width: 100%; - max-width: 1200px; - margin: 0 auto; -} - -.section-title { - font-size: 2.5rem; - text-align: center; - margin-bottom: 0.5rem; - color: #ffffff; - font-weight: 800; -} - -.section-title.text-left { - text-align: left; -} - -.section-subtitle { - font-size: 1.1rem; - text-align: center; - color: #c5bedb; - margin-bottom: 3.5rem; - max-width: 700px; - margin-left: auto; - margin-right: auto; -} - -.features { - background: #130a2e; -} - -.features-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 2.5rem; -} - -.feature-card { - background: #21134a; - border: 1px solid #3c257d; - border-radius: 20px; - padding: 2.5rem 2rem; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.feature-card:hover { - transform: translateY(-8px); - box-shadow: 0 15px 35px -10px rgba(102, 126, 234, 0.3); - border-color: #667eea; -} - -.feature-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 50px; - height: 50px; - border-radius: 12px; - background: rgba(102, 126, 234, 0.15); - color: #00f2fe; - margin-bottom: 1.5rem; - transition: all 0.3s ease; -} - -.feature-card:hover .feature-icon { - background: linear-gradient(135deg, #00f2fe 0%, #667eea 100%); - color: #130a2e; -} - -.feature-card h3 { - font-size: 1.35rem; - margin-bottom: 0.75rem; - color: #ffffff; - font-weight: 700; -} - -.feature-card p { - color: #c5bedb; - font-size: 0.95rem; - line-height: 1.6; -} - -.about { - background: #170d37; -} - -.about-container { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - max-width: 800px; - margin: 0 auto; -} - -.about-content h2 { - margin-bottom: 1.5rem; -} - -.about-content p { - color: #c5bedb; - font-size: 1.05rem; - line-height: 1.7; - margin-bottom: 1.5rem; -} - -.about-points { - margin-top: 2rem; - display: inline-flex; - flex-direction: column; - align-items: flex-start; - gap: 1rem; -} - -.about-point { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.check-icon { - color: #00f2fe; - flex-shrink: 0; -} - -.about-point span { - font-size: 0.95rem; - font-weight: 600; - color: #ffffff; -} - -/* Cleaned up removed SVG wrapper classes */ - -.testimonials { - background: #130a2e; -} - -.testimonials-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 2.5rem; -} - -.testimonial-card { - background: #21134a; - border: 1px solid #3c257d; - border-radius: 20px; - padding: 2.5rem; - display: flex; - flex-direction: column; - justify-content: space-between; - transition: all 0.3s ease; -} - -.testimonial-card:hover { - box-shadow: 0 15px 35px -10px rgba(102, 126, 234, 0.3); - border-color: #667eea; -} - -.stars { - color: #f59e0b; - font-size: 1.1rem; - margin-bottom: 1rem; -} - -.testimonial-text { - font-size: 1rem; - color: #c5bedb; - font-style: italic; - line-height: 1.6; - margin-bottom: 2rem; -} - -.user-info { - display: flex; - align-items: center; - gap: 1rem; -} - -.avatar { - width: 44px; - height: 44px; - border-radius: 50%; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; - font-size: 0.9rem; -} - -.user-info h4 { - font-size: 0.95rem; - color: #ffffff; - margin-bottom: 0.15rem; -} - -.user-info span { - font-size: 0.8rem; - color: #c5bedb; -} - -.footer { - background: #0b061d; - color: #a59ec0; - padding: 5rem 2rem 2rem; - border-top: 1px solid #21134a; -} - -.footer-container { - display: grid; - grid-template-columns: 2fr 1fr 1fr 1.2fr; - gap: 4rem; - margin-bottom: 4rem; -} - -.footer-brand { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -.footer-brand .logo { - color: white; -} - -.footer-brand p { - font-size: 0.95rem; - line-height: 1.6; - max-width: 320px; -} - -.social-icons { - display: flex; - gap: 1rem; -} - -.social-icon { - display: flex; - align-items: center; - justify-content: center; - width: 38px; - height: 38px; - border-radius: 50%; - background: #21134a; - color: #a59ec0; - transition: all 0.3s ease; -} - -.social-icon:hover { - background: linear-gradient(135deg, #00f2fe 0%, #667eea 100%); - color: #130a2e; - transform: translateY(-3px); -} - -.footer-links h4 { - color: white; - font-size: 1.05rem; - font-weight: 600; - margin-bottom: 1.5rem; -} - -.footer-links ul { - list-style: none; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.footer-links a { - color: #a59ec0; - text-decoration: none; - font-size: 0.95rem; - transition: color 0.3s ease; -} - -.footer-links a:hover { - color: white; -} - -.footer-links li { - font-size: 0.95rem; - line-height: 1.5; -} - -.footer-bottom { - border-top: 1px solid #21134a; - padding-top: 2rem; -} - -.footer-bottom-container { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 1rem; - font-size: 0.875rem; -} - -.footer-legal { - display: flex; - gap: 1rem; - align-items: center; -} - -.footer-legal a { - color: #a59ec0; - text-decoration: none; - transition: color 0.3s ease; -} - -.footer-legal a:hover { - color: white; -} - -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(30px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (max-width: 968px) { - .footer-container { - grid-template-columns: 1fr 1fr; - gap: 3rem; - } -} - -@media (max-width: 768px) { -/* About section media queries removed as it is now centered by default */ - .section { - padding: 4rem 1.5rem; - } - .footer-container { - grid-template-columns: 1fr; - gap: 2.5rem; - } - .footer-bottom-container { - flex-direction: column; - text-align: center; - } -} -`, - javascript: `function handleCTA() { - alert('Welcome! This is where you would redirect to signup or more info.'); -} - -// Add smooth scrolling for navigation links -document.addEventListener('DOMContentLoaded', function() { - const navLinks = document.querySelectorAll('.nav-links a'); - - navLinks.forEach(link => { - link.addEventListener('click', function(e) { - const targetId = this.getAttribute('href'); - const targetElement = document.querySelector(targetId); - - if (targetElement) { - targetElement.scrollIntoView({ - behavior: 'smooth' - }); - } - }); - }); -}); - -/* Interactive effects script removed */`, - }, - }, - { - id: "interactive-card", - name: "Interactive Card", - description: "Animated card component", - icon: , - content: { - html: ` - - - - - Interactive Card - - -
-
-
-

Interactive Card

- Active -
-
-

Hover over me to see the magic happen!

-
-
- 42 - Projects -
-
- 1.2k - Users -
-
-
- -
-
- -`, - css: `body { - margin: 0; - padding: 0; - min-height: 100vh; - background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - display: flex; - align-items: center; - justify-content: center; -} - -.container { - perspective: 1000px; -} - -.card { - width: 350px; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(10px); - border-radius: 20px; - padding: 2rem; - color: white; - border: 1px solid rgba(255, 255, 255, 0.2); - transition: all 0.3s ease; - cursor: pointer; - position: relative; - overflow: hidden; -} - -.card::before { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent); - transition: left 0.5s; -} - -.card:hover::before { - left: 100%; -} - -.card:hover { - transform: translateY(-10px) rotateX(5deg); - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); -} - -.card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; -} - -.card-header h2 { - margin: 0; - font-size: 1.5rem; -} - -.status { - background: #4ade80; - padding: 0.25rem 0.75rem; - border-radius: 20px; - font-size: 0.8rem; - font-weight: 600; -} - -.card-content p { - margin-bottom: 1.5rem; - opacity: 0.9; - line-height: 1.6; -} - -.stats { - display: flex; - gap: 2rem; - margin-bottom: 1.5rem; -} - -.stat { - text-align: center; -} - -.stat-number { - display: block; - font-size: 2rem; - font-weight: bold; - color: #4ade80; -} - -.stat-label { - font-size: 0.9rem; - opacity: 0.8; -} - -.card-footer { - display: flex; - gap: 1rem; -} - -.btn-primary, .btn-secondary { - padding: 0.75rem 1.5rem; - border: none; - border-radius: 10px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; - flex: 1; -} - -.btn-primary { - background: #4ade80; - color: #1f2937; -} - -.btn-primary:hover { - background: #22c55e; - transform: translateY(-2px); -} - -.btn-secondary { - background: transparent; - color: white; - border: 1px solid rgba(255, 255, 255, 0.3); -} - -.btn-secondary:hover { - background: rgba(255, 255, 255, 0.1); - transform: translateY(-2px); -}`, - javascript: `function handleAction() { - const card = document.getElementById('interactiveCard'); - - // Add a pulse effect - card.style.animation = 'pulse 0.6s ease-in-out'; - - // Show success message - setTimeout(() => { - alert('Action completed successfully!'); - card.style.animation = ''; - }, 600); -} - -// Add CSS animation dynamically -const style = document.createElement('style'); -style.textContent = \` - @keyframes pulse { - 0% { transform: scale(1); } - 50% { transform: scale(1.05); } - 100% { transform: scale(1); } - } -\`; -document.head.appendChild(style); - -// Add particle effect on hover -document.addEventListener('DOMContentLoaded', function() { - const card = document.getElementById('interactiveCard'); - - card.addEventListener('mouseenter', function() { - createParticles(); - }); -}); - -function createParticles() { - const container = document.querySelector('.container'); - - for (let i = 0; i < 6; i++) { - const particle = document.createElement('div'); - particle.style.cssText = \` - position: absolute; - width: 4px; - height: 4px; - background: #4ade80; - border-radius: 50%; - pointer-events: none; - animation: float 2s ease-out forwards; - left: \${Math.random() * 100}%; - top: \${Math.random() * 100}%; - \`; - - container.appendChild(particle); - - setTimeout(() => { - particle.remove(); - }, 2000); - } -} - -// Add float animation -const floatStyle = document.createElement('style'); -floatStyle.textContent = \` - @keyframes float { - 0% { - opacity: 1; - transform: translateY(0px); - } - 100% { - opacity: 0; - transform: translateY(-50px); - } - } -\`; -document.head.appendChild(floatStyle);`, - + html: `\n\n\n \n \n Landing Page\n\n\n
\n

Welcome to the Future

Build amazing things

\n\n`, + css: `body{margin:0;font-family:'Segoe UI',sans-serif}.header{background:#130a2e;padding:1rem 2rem;position:fixed;width:100%;top:0;z-index:1000}.nav{display:flex;justify-content:space-between;align-items:center}.logo{color:white;font-size:1.5rem;font-weight:bold}.hero{height:100vh;background:#130a2e;display:flex;align-items:center;justify-content:center;text-align:center;color:white}.hero-content h1{font-size:4rem;margin-bottom:1rem}.hero-content p{color:#c5bedb;margin-bottom:2rem}.hero-content button{padding:1rem 2.5rem;border:none;border-radius:50px;cursor:pointer;font-weight:700;font-size:1.1rem}`, + javascript: `console.log('Landing page loaded!')`, }, }, { @@ -1080,6 +156,8 @@ document.head.appendChild(floatStyle);`, }, ] +type LayoutType = "split" | "preview" | "code" + export default function CodeEditor() { const [code, setCode] = useState(() => { if (typeof window === "undefined") return templates[0].content @@ -1088,20 +166,203 @@ export default function CodeEditor() { const sharedCode = urlParams.get("code") if (sharedCode) return JSON.parse(safeBase64Decode(sharedCode)) as CodeContent } catch { - // ignore invalid share URL + // invalid share URL — fall through } try { const saved = localStorage.getItem("webify_code") if (saved) return JSON.parse(saved) as CodeContent } catch { - // ignore corrupted local storage + // corrupted storage — fall through } return templates[0].content }) + const [layout, setLayout] = useState("split") const [activeTab, setActiveTab] = useState("html") + const [isFullscreen, setIsFullscreen] = useState(false) const [theme, setTheme] = useState<"light" | "dark">("light") + const [paletteOpen, setPaletteOpen] = useState(false) + const [autoRun, setAutoRun] = useState(true) + const [splitRatio, setSplitRatio] = useState(50) + const [isResizing, setIsResizing] = useState(false) + + + // use effect for handling full screen mode + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(Boolean(document.fullscreenElement)); + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => { + document.removeEventListener("fullscreenchange", handleFullscreenChange); + }; + }, []); + + const handleFullscreenToggle = async () => { + try { + if (document.fullscreenElement) { + if (document.exitFullscreen) { + await document.exitFullscreen(); + } + return; + } + + if (isFullscreen) { + setIsFullscreen(false); + return; + } + + if (containerRef.current?.requestFullscreen) { + await containerRef.current.requestFullscreen(); + return; + } + + setIsFullscreen(true); + } catch (err) { + console.error("Error attempting to toggle fullscreen:", err); + setIsFullscreen((prev) => !prev); + } + }; + +const containerRef = useRef(null) +const previewRef = useRef(null) +const [isMobile, setIsMobile] = useState(false) + +useEffect(() => { + const updateIsMobile = () => { + setIsMobile(window.innerWidth < 768) + } + + updateIsMobile() + window.addEventListener("resize", updateIsMobile) + + return () => { + window.removeEventListener("resize", updateIsMobile) + } +}, []) + +const handleDragStart = () => { + isDragging.current = true; + setIsResizing(true); + document.body.style.userSelect = "none"; +}; + +const handleDragMove = useCallback((clientX: number, clientY: number) => { + if (!isDragging.current || !containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + + let newRatio; + if (isMobile) { + newRatio = ((clientY - rect.top) / rect.height) * 100; + } else { + newRatio = ((clientX - rect.left) / rect.width) * 100; + } + + const clampedRatio = Math.max(20, Math.min(80, newRatio)); + setSplitRatio(clampedRatio); +}, [isMobile]); + +const handleMouseMove = useCallback((e: globalThis.MouseEvent) => { + handleDragMove(e.clientX, e.clientY); +}, [handleDragMove]); + +const handleTouchMove = useCallback((e: globalThis.TouchEvent) => { + if (isDragging.current) { + handleDragMove(e.touches[0].clientX, e.touches[0].clientY); + } +}, [handleDragMove]); + +const handleDragEnd = useCallback(() => { + isDragging.current = false; + setIsResizing(false); + document.body.style.userSelect = "auto"; + document.body.style.cursor = "default"; +}, []); + + // Tracks which template is currently active + + const [isMobile, setIsMobile] = useState(false) + const [consoleErrors, setConsoleErrors] = useState>([]) + const [runtimeError, setRuntimeError] = useState<{ + message: string; + line: number | null; + column: number | null; + } | null>(null) + const [consoleOpen, setConsoleOpen] = useState(false) + const [moreSheetOpen, setMoreSheetOpen] = useState(false) + + const [currentTemplateId, setCurrentTemplateId] = useState(null) + const [templateSnapshots, setTemplateSnapshots] = useState>(() => { + if (typeof window === "undefined") return {} + try { + const saved = localStorage.getItem("webify_template_snapshots") + if (saved) return JSON.parse(saved) as Record + } catch { + // corrupted storage — fall through + } + return {} + }) + + const isDragging = useRef(false) + const containerRef = useRef(null) const previewRef = useRef(null) + const activeEditorRef = useRef(null) + const codeRef = useRef(code) + + const htmlValidation = useMemo(() => validateHtmlSyntax(code.html), [code.html]) + + useEffect(() => { codeRef.current = code }, [code]) + + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth < 768) + handleResize() + window.addEventListener("resize", handleResize) + return () => window.removeEventListener("resize", handleResize) + }, []) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === "WEBIFY_ERROR") { + setRuntimeError({ + message: event.data.message, + line: event.data.line ?? null, + column: event.data.column ?? event.data.col ?? null, + }) + setConsoleErrors((prev) => [...prev, { + message: event.data.message, + line: event.data.line, + col: event.data.col, + }]) + setConsoleOpen(true) + } + } + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, []) + + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement) + } + document.addEventListener("fullscreenchange", handleFullscreenChange) + return () => document.removeEventListener("fullscreenchange", handleFullscreenChange) + }, []) + + useEffect(() => { + const timer = setTimeout(() => { + try { localStorage.setItem('webify_code', JSON.stringify(code)) } catch {} + }, 500) + return () => clearTimeout(timer) + }, [code]) + + useEffect(() => { + const timer = setTimeout(() => { + try { localStorage.setItem('webify_template_snapshots', JSON.stringify(templateSnapshots)) } catch {} + }, 500) + return () => clearTimeout(timer) + }, [templateSnapshots]) useEffect(() => { const savedTheme = localStorage.getItem("theme") as "light" | "dark" | null @@ -1115,44 +376,134 @@ export default function CodeEditor() { } }, []) + const toggleTheme = () => { + if (theme === "light") { + setTheme("dark") + document.documentElement.classList.add("dark") + localStorage.setItem("theme", "dark") + } else { + setTheme("light") + document.documentElement.classList.remove("dark") + localStorage.setItem("theme", "light") + } + } + + const handleDragStart = useCallback(() => { + isDragging.current = true + setIsResizing(true) + document.body.style.userSelect = "none" + }, []) + + const handleDragMove = useCallback((clientX: number, clientY: number) => { + if (!isDragging.current || !containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + let newRatio: number + if (isMobile) { + newRatio = ((clientY - rect.top) / rect.height) * 100 + } else { + newRatio = ((clientX - rect.left) / rect.width) * 100 + } + setSplitRatio(Math.max(20, Math.min(80, newRatio))) + }, [isMobile]) + + const handleDragEnd = useCallback(() => { + isDragging.current = false + setIsResizing(false) + document.body.style.userSelect = "auto" + document.body.style.cursor = "default" + }, []) + + const handleMouseMove = useCallback((e: globalThis.MouseEvent) => handleDragMove(e.clientX, e.clientY), [handleDragMove]) + const handleTouchMove = useCallback((e: globalThis.TouchEvent) => { + if (isDragging.current) { + e.preventDefault() + handleDragMove(e.touches[0].clientX, e.touches[0].clientY) + } + }, [handleDragMove]) + useEffect(() => { - const timer = setTimeout(() => { - try { - localStorage.setItem("webify_code", JSON.stringify(code)) - } catch { - // ignore storage quota errors - } - }, 300) - return () => clearTimeout(timer) - }, [code]) + window.addEventListener("mousemove", handleMouseMove) + window.addEventListener("mouseup", handleDragEnd) + window.addEventListener("touchmove", handleTouchMove, { passive: false }) + window.addEventListener("touchend", handleDragEnd) + return () => { + window.removeEventListener("mousemove", handleMouseMove) + window.removeEventListener("mouseup", handleDragEnd) + window.removeEventListener("touchmove", handleTouchMove) + window.removeEventListener("touchend", handleDragEnd) + } + }, [handleMouseMove, handleTouchMove, handleDragEnd]) useEffect(() => { + if (!previewRef.current || !autoRun) return + // clear runtime error on each recompile; timeout errors are caught by the iframe's own watchdog + setRuntimeError(null) + if (!htmlValidation.isValid) { + previewRef.current.srcdoc = createPreviewErrorHtml(htmlValidation.message ?? "Invalid HTML syntax.") + return + } + const debounceTimer = setTimeout(() => { + if (!previewRef.current) return + const combinedCode = `Live Preview${code.html}Live Preview${code.html}\n\n`) zip.file("style.css", code.css) zip.file("script.js", code.javascript) const blob = await zip.generateAsync({ type: "blob" }) @@ -1162,91 +513,438 @@ export default function CodeEditor() { a.download = "webify-project.zip" document.body.appendChild(a) a.click() - a.remove() + document.body.removeChild(a) URL.revokeObjectURL(url) + toast("Download started", { description: "Saved as webify-project.zip" }) + } + + const importCode = () => { + const input = document.createElement("input") + input.type = "file" + input.accept = ".html" + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (file) { + const reader = new FileReader() + reader.onload = (e) => { + const content = e.target?.result as string + const htmlMatch = content.match(/]*>([\s\S]*?)<\/body>/i) + const cssMatch = content.match(/]*>([\s\S]*?)<\/style>/i) + const jsMatch = content.match(/]*>([\s\S]*?)<\/script>/i) + setCode({ + html: htmlMatch ? htmlMatch[1].trim() : "", + css: cssMatch ? cssMatch[1].trim() : "", + javascript: jsMatch ? jsMatch[1].trim() : "", + }) + toast("File imported", { description: "HTML file imported." }) + } + reader.readAsText(file) + } + } + input.click() } const copyShareLink = async () => { + if (typeof window === "undefined") return try { - const share = `${window.location.origin}?code=${safeBase64Encode(JSON.stringify(code))}` - await navigator.clipboard.writeText(share) - toast.success("Share link copied") + const url = `${window.location.origin}?code=${safeBase64Encode(JSON.stringify({ html: code.html, css: code.css, javascript: code.javascript }))}` + await navigator.clipboard.writeText(url) + toast("Link copied", { description: "Shareable link copied to clipboard." }) } catch { - toast.error("Could not copy share link") + toast.error("Copy failed", { description: "Could not copy the share link." }) } } - const toggleTheme = () => { - const next = theme === "light" ? "dark" : "light" - setTheme(next) - if (next === "dark") { - document.documentElement.classList.add("dark") - } else { - document.documentElement.classList.remove("dark") - } - localStorage.setItem("theme", next) + const handleAIGenerate = (generated: { html: string; css: string; javascript: string }) => { + setCode({ + html: generated.html, + css: generated.css, + javascript: generated.javascript, + }) + setActiveTab("html") +if (layout === "preview") setLayout("split") } + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const sharedCode = urlParams.get("code") + if (sharedCode) { + try { + const decoded = JSON.parse(safeBase64Decode(sharedCode)) + setCode(decoded) + toast("Shared code loaded", { description: "Shared code loaded." }) + } catch { + toast.error("Invalid share link", { description: "Could not load shared code." }) + } + } + }, []) + + const shareUrl = typeof window !== "undefined" + ? `${window.location.origin}?code=${safeBase64Encode(JSON.stringify({ html: code.html, css: code.css, javascript: code.javascript }))}` + : "" + + const commands = useMemo(() => { + const layoutCmd = (id: string, label: string, value: LayoutType, icon: React.ReactNode): Command => ({ + id, label, group: "Layout", icon, + keywords: "view layout panel", + description: layout === value ? "Active" : undefined, + perform: () => setLayout(value), + }) + const tabCmd = (id: string, label: string, value: keyof CodeContent, icon: React.ReactNode): Command => ({ + id, label, group: "Editor", icon, + keywords: "tab file language", + description: activeTab === value ? "Active" : undefined, + perform: () => { setActiveTab(value); if (layout === "preview") setLayout("split") }, + }) + return [ + layoutCmd("layout-code", "Code only", "code", ), + layoutCmd("layout-split", "Split view", "split", ), + layoutCmd("layout-preview", "Preview only", "preview", ), + tabCmd("tab-html", "Go to HTML", "html", ), + tabCmd("tab-css", "Go to CSS", "css", ), + tabCmd("tab-js", "Go to JavaScript", "javascript", ), + { + id: "editor-undo", label: "Undo", group: "Editor", icon: , + keywords: "ctrl z revert history undo", + perform: () => { const ed = activeEditorRef.current; if (ed) { ed.focus(); ed.trigger("palette", "undo", null) } }, + }, + { + id: "editor-redo", label: "Redo", group: "Editor", icon: , + keywords: "ctrl y ctrl shift z history redo", + perform: () => { const ed = activeEditorRef.current; if (ed) { ed.focus(); ed.trigger("palette", "redo", null) } }, + }, + { id: "action-format", label: "Format code", group: "Actions", icon: , keywords: "prettier format beautify", perform: formatCode }, + { id: "action-import", label: "Import HTML file", group: "Actions", icon: , keywords: "open upload load", perform: importCode }, + { id: "action-download", label: "Download project", group: "Actions", icon: , keywords: "export save html", perform: downloadCode }, + { id: "action-share", label: "Copy shareable link", group: "Actions", icon: , keywords: "url clipboard share", perform: copyShareLink }, + { + id: "action-fullscreen", label: isFullscreen ? "Exit fullscreen" : "Enter fullscreen", group: "Actions", + icon: isFullscreen ? : , + keywords: "expand maximize zoom", + perform: () => setIsFullscreen((v) => !v), + }, + { + id: "action-theme", label: theme === "light" ? "Switch to dark mode" : "Switch to light mode", group: "Actions", + icon: theme === "light" ? : , + keywords: "appearance dark light color", + perform: toggleTheme, + }, + ...templates.map((t) => ({ + id: `template-${t.id}`, label: t.name, description: t.description, group: "Templates", icon: t.icon, + keywords: `template starter ${t.name}`, + perform: () => loadTemplate(t), + })), + ] + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layout, activeTab, theme, isFullscreen, code]) + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault() + e.stopPropagation() + setPaletteOpen((open) => !open) + } + } + window.addEventListener("keydown", onKeyDown, true) + return () => window.removeEventListener("keydown", onKeyDown, true) + }, []) + + const bottomNavItems = [ + { label: "Code", icon: , action: () => setLayout("code"), active: layout === "code" }, + { label: "Split", icon: , action: () => setLayout("split"), active: layout === "split" }, + { label: "Preview", icon: , action: () => setLayout("preview"), active: layout === "preview" }, + { label: "Save", icon: , action: downloadCode, active: false }, + { label: "More", icon: , action: () => setMoreSheetOpen(true), active: moreSheetOpen }, + ] + return ( -
-
- - - Webify - - -
- - - + <> + + + {/* More sheet (mobile) */} + {moreSheetOpen && ( +
+
setMoreSheetOpen(false)} /> +
+
+
+ More actions + +
+
+ {[ + { label: "Import file", icon: , action: importCode }, + { label: "Share link", icon: , action: copyShareLink }, + { label: "Open in tab", icon: , action: () => { if (previewRef.current?.src) window.open(previewRef.current.src, "_blank") } }, + { label: isFullscreen ? "Exit fullscreen" : "Fullscreen", icon: isFullscreen ? : , action: () => { setIsFullscreen(v => !v); setMoreSheetOpen(false) } }, + { label: "Command palette", icon: , action: () => { setMoreSheetOpen(false); setPaletteOpen(true) } }, + { label: theme === "light" ? "Dark mode" : "Light mode", icon: theme === "light" ? : , action: () => { toggleTheme(); setMoreSheetOpen(false) } }, + ].map((item) => ( + + ))} +
+
-
- -
- - setActiveTab(value as keyof CodeContent)} className="flex-1 flex flex-col overflow-hidden"> -
- - HTML - CSS - JS - + )} + +
+ + {/* HEADER */} +
+
+
+ + + Webify + +
-
- - handleCodeChange("html", v)} theme={theme} /> - - - handleCodeChange("css", v)} theme={theme} /> - - - handleCodeChange("javascript", v)} theme={theme} /> - + + + +
+
+ + + +
+
+ + + + + +
- - - - -
-
- - Live Preview + +
+ +
-