"use client" import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { useTranslations } from 'next-intl' import { pageSchema, type Page } from '@/lib/schema/page' import { exportPage } from '@/lib/exporter/html' import { AssetManager } from '@/lib/assets/assetManager' import { buildZip } from '@/lib/exporter/zip' import { buildExtrasForPage } from '@/lib/exporter/extras' import { buildAssetsMetadata } from '@/lib/exporter/assets.meta' import { buildAssetsIndex } from '@/lib/exporter/assets.index' import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index' import type { Section } from '@/lib/state/store' import { createFormStore, type FormState } from '@/lib/state/formStore' import FormBuilderPanel from '@/components/FormBuilderPanel' import BuilderShell from '@/components/BuilderShell' import SectionCanvas from '@/components/SectionCanvas' import SidebarComponents from '@/components/SidebarComponents' function download(filename: string, data: Uint8Array) { const ab = data.slice().buffer as ArrayBuffer const blob = new Blob([ab], { type: 'application/zip' }) try { if (typeof window !== 'undefined') { const w = window as unknown as { __captureExport?: boolean; __lastBlob?: Blob } if (w.__captureExport) { w.__lastBlob = blob } } } catch {} const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) } export default function BuilderClientPage() { const t = useTranslations() const [locale, setLocale] = useState<'en' | 'ko'>('en') const [title, setTitle] = useState('My Landing') const [primaryColor, setPrimaryColor] = useState('#0ea5e9') const [fontFamily, setFontFamily] = useState('Inter') const [sections, setSections] = useState([]) const [selectedIndex, setSelectedIndex] = useState(null) const [seoTitle, setSeoTitle] = useState('') const [seoDescription, setSeoDescription] = useState('') const [ogImage, setOgImage] = useState('') const [ga4MeasurementId, setGa4MeasurementId] = useState('') const [metaPixelId, setMetaPixelId] = useState('') // Export Options (manifest/robots overrides) const [manifestName, setManifestName] = useState('') const [manifestShortName, setManifestShortName] = useState('') const [manifestStartUrl, setManifestStartUrl] = useState('') const [manifestDisplay, setManifestDisplay] = useState('') const [manifestThemeColor, setManifestThemeColor] = useState('') const [robotsDisallow, setRobotsDisallow] = useState('') const [robotsMeta, setRobotsMeta] = useState('') const [sheetsMode, setSheetsMode] = useState<'auto' | 'include' | 'exclude'>('auto') const [assetPathStrategy, setAssetPathStrategy] = useState<'flat' | 'grouped'>('flat') // Font optimization: allow toggling Google Fonts preconnect links in exported HTML const [fontPreconnect, setFontPreconnect] = useState(true) const [fontPreload, setFontPreload] = useState(false) const [imageLoading, setImageLoading] = useState<'lazy' | 'eager'>('lazy') const [imageDecoding, setImageDecoding] = useState<'async' | 'sync'>('async') const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => { if (typeof window !== 'undefined') { try { const saved = localStorage.getItem('builder.viewport') as 'desktop' | 'mobile' | null if (saved === 'desktop' || saved === 'mobile') return saved } catch {} } return 'desktop' }) const fileInputRef = useRef(null) const assetManagerRef = useRef( new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'] }) ) useEffect(() => { try { localStorage.setItem('builder.viewport', viewport) } catch {} }, [viewport]) const [am, setAm] = useState(null) useEffect(() => { setAm(assetManagerRef.current) }, []) const [selectedFileName, setSelectedFileName] = useState('') // form store (stable instance without ref access in render) const formStore = useMemo(() => createFormStore(), []) const formState: FormState = useSyncExternalStore( formStore.subscribe, formStore.getState, formStore.getState ) const addHero = useCallback(async () => { const fileEl = fileInputRef.current if (fileEl && fileEl.files && fileEl.files.length > 0) { const file = fileEl.files[0] const buf = new Uint8Array(await file.arrayBuffer()) if (am) { await am.add({ name: file.name, type: file.type, bytes: buf }) } setSelectedFileName(file.name) } // 파일이 없어도 Hero 섹션은 추가(이미지는 placeholder 사용) const hero: Section = { type: 'hero', props: { heading: 'Welcome', subheading: '' } } setSections((prev) => [...prev, hero]) }, [am]) const addFaq = useCallback(() => { const faq: Section = { type: 'faq', props: { items: [] } } setSections((prev) => [...prev, faq]) }, []) const addCta = useCallback(() => { const cta: Section = { type: 'cta', props: { text: 'Get Started', href: '#' } } setSections((prev) => [...prev, cta]) }, []) const addFeatures = useCallback(() => { const features: Section = { type: 'features', props: { items: ['Feature A', 'Feature B', 'Feature C'] } } setSections((prev) => [...prev, features]) }, []) const addTestimonials = useCallback(() => { const testimonials: Section = { type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great product!' }, { author: 'John', quote: 'Loved it.' }] }, } setSections((prev) => [...prev, testimonials]) }, []) const removeAt = useCallback((idx: number) => { setSections((prev) => prev.filter((_, i) => i !== idx)) }, []) const moveSection = useCallback((from: number, to: number) => { setSections((prev) => { if (from < 0 || to < 0 || from >= prev.length || to >= prev.length) return prev const next = prev.slice() const [sp] = next.splice(from, 1) next.splice(to, 0, sp) return next }) }, []) const pageData = useMemo(() => { const firstName = selectedFileName const mappedSections = sections.map((s: Section) => { if (s.type === 'hero') { return { type: 'hero' as const, props: { heading: s.props?.heading || 'Welcome', subheading: s.props?.subheading || '', imageUrl: firstName ? `local:${firstName}` : 'https://via.placeholder.com/800x400', }, } } return s }) return ({ title, locale, theme: { primaryColor, fontFamily }, sections: (mappedSections as unknown) as Page['sections'], form: formState, analytics: { ga4MeasurementId, metaPixelId, customHeadHtml: '' }, seo: { title: seoTitle || title, description: (seoDescription && seoDescription.trim()) ? seoDescription : 'Landing page', ogImage: (ogImage && ogImage.trim()) ? ogImage : undefined }, } as unknown) as Page }, [title, locale, primaryColor, fontFamily, sections, formState, selectedFileName, seoTitle, seoDescription, ga4MeasurementId, metaPixelId, ogImage]) // test-only: expose preview exporter for E2E to read exported HTML/CSS/JS directly useEffect(() => { try { if (typeof window !== 'undefined') { const w = window as unknown as { __exportPreview?: () => { html: string; css: string; js: string } } // Build an effective AssetManager based on selected path strategy const buildEffectiveAm = (): AssetManager | undefined => { const base = am ?? undefined if (!base) return undefined if (assetPathStrategy === 'flat') return base const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy }) for (const ref of base.list()) { // re-add to apply new path strategy and recreate byOriginal mapping clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes }) } return clone } w.__exportPreview = () => { try { // Export preview with current optimization options return exportPage(pageSchema.parse(pageData), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined }) } catch { const fixed: Page = { ...pageData, form: { actionUrl: pageData.form?.actionUrl ?? '', fields: pageData.form?.fields ?? [], spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 }, }, } return exportPage(pageSchema.parse(fixed), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined }) } } } } catch {} }, [pageData, am, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy]) const doExport = useCallback(async () => { let valid = pageData as Page try { valid = pageSchema.parse(pageData) } catch { const fixed: Page = { ...pageData, form: { actionUrl: pageData.form?.actionUrl ?? '', fields: pageData.form?.fields ?? [], spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 }, }, } as Page valid = pageSchema.parse(fixed) } // Build an effective AssetManager based on selected path strategy const buildEffectiveAm = (): AssetManager | undefined => { const base = am ?? undefined if (!base) return undefined if (assetPathStrategy === 'flat') return base const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy }) for (const ref of base.list()) { clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes }) } return clone } // Respect font, image, robots meta optimization toggles when exporting const exported = exportPage(valid, { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined }) try { if (typeof window !== 'undefined') { const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } } if (w.__captureExport) { w.__lastExport = exported } } } catch {} const effectiveAm = (() => { const base = am ?? undefined if (!base) return undefined if (assetPathStrategy === 'flat') return base const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy }) for (const ref of base.list()) { clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes }) } return clone })() const assets = effectiveAm ? effectiveAm.toZipStructure() : {} // Build overrides from UI options const manifestOverrideBase: Record = {} if (manifestName && manifestName.trim().length > 0) manifestOverrideBase.name = manifestName.trim() if (manifestShortName && manifestShortName.trim().length > 0) manifestOverrideBase.short_name = manifestShortName.trim() if (manifestStartUrl && manifestStartUrl.trim().length > 0) manifestOverrideBase.start_url = manifestStartUrl.trim() if (manifestDisplay && manifestDisplay.trim().length > 0) manifestOverrideBase.display = manifestDisplay.trim() if (manifestThemeColor && manifestThemeColor.trim().length > 0) { const v = manifestThemeColor.trim() const hexOk = /^#([0-9a-fA-F]{3}){1,2}$/.test(v) if (hexOk) { manifestOverrideBase.theme_color = v } } const manifestOverride = Object.keys(manifestOverrideBase).length > 0 ? (manifestOverrideBase as { name?: string; short_name?: string; start_url?: string; display?: string; theme_color?: string }) : undefined const robotsTextOverride = (() => { const lines = (robotsDisallow || '').split(/\n+/).map((s) => s.trim()).filter((s) => s.length > 0) if (lines.length === 0) return undefined const disallowLines = lines.map((l) => (l.startsWith('Disallow:') ? l : `Disallow: ${l}`)) return `User-agent: *\n${disallowLines.join('\n')}` })() const baseExtras = buildExtrasForPage(valid, { robotsText: robotsTextOverride, manifest: manifestOverride, sheetsMode }) const extras: Record = { ...baseExtras, ...(am ? { 'assets.json': buildAssetsMetadata(am), 'assets.index.json': buildAssetsIndex(am), 'assets.integrity.index.json': buildAssetsIntegrityIndex(am), } : {}), } const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets, extras }) download('landing.zip', zipBytes) }, [pageData, am, manifestName, manifestShortName, manifestStartUrl, manifestDisplay, manifestThemeColor, robotsDisallow, sheetsMode, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy]) return (

{t('builder.title')}

} center={
setTitle(e.target.value)} />
setPrimaryColor(e.target.value)} />
setFontFamily(e.target.value)} />
setSelectedIndex(i)} />

Export Options

{/* Asset path strategy: flat vs grouped */} {/* Font optimization toggle for Google Fonts preconnect */}