EmForge Builder Beta
AI Website Builder
Powered by Claude
🔒 preview.emforge.io
' + ''; } function shadeHex(hex, amount) { var h = hex.replace('#',''); if (h.length === 3) h = h.split('').map(function(c){return c+c;}).join(''); var r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16); r = Math.max(0,Math.min(255,r+amount)); g = Math.max(0,Math.min(255,g+amount)); b = Math.max(0,Math.min(255,b+amount)); return '#' + [r,g,b].map(function(v){return v.toString(16).padStart(2,'0');}).join(''); } function renderPreview() { previewFrame.srcdoc = generatePreviewHTML(state); } // --- Attachments --- function renderAttachments() { attachmentsEl.innerHTML = ''; pendingAttachments.forEach(function(a){ var chip = document.createElement('div'); chip.className = 'chat-chip' + (a.status === 'uploading' ? ' uploading' : '') + (a.status === 'error' ? ' error' : ''); var imgSrc = a.url || a.previewDataUrl || ''; if (imgSrc) { var img = document.createElement('img'); img.src = imgSrc; chip.appendChild(img); } var name = document.createElement('span'); name.className = 'chat-chip-name'; name.textContent = a.name; chip.appendChild(name); var x = document.createElement('button'); x.className = 'chat-chip-x'; x.textContent = '×'; x.title = 'Remove'; x.addEventListener('click', function(){ pendingAttachments = pendingAttachments.filter(function(p){ return p.id !== a.id; }); renderAttachments(); }); chip.appendChild(x); attachmentsEl.appendChild(chip); }); } // --- Auth + saved sites --- function setSaveStatus(cls, text) { saveStatus.className = 'save-status' + (cls ? ' ' + cls : ''); saveStatus.textContent = text || ''; } function renderAuthUI() { if (currentUser) { authBtnLabel.textContent = currentUser.email.split('@')[0]; authDropdown.innerHTML = ''; var header = document.createElement('div'); header.className = 'dropdown-header'; header.textContent = currentUser.email; authDropdown.appendChild(header); var out = document.createElement('button'); out.className = 'dropdown-item'; out.textContent = 'Sign out'; out.addEventListener('click', signOut); authDropdown.appendChild(out); } else { authBtnLabel.textContent = 'Sign in'; authDropdown.innerHTML = ''; var form = document.createElement('form'); form.className = 'auth-form'; form.innerHTML = '' + ''; var msg = document.createElement('div'); msg.className = 'auth-msg'; msg.id = 'authMsg'; msg.textContent = 'Sign in to save sites and come back later.'; form.appendChild(msg); authDropdown.appendChild(form); form.addEventListener('submit', async function(e){ e.preventDefault(); var email = document.getElementById('authEmail').value.trim(); var btn = document.getElementById('authSubmit'); var m = document.getElementById('authMsg'); if (!email) return; btn.disabled = true; btn.textContent = 'Sending…'; m.className = 'auth-msg'; try { var res = await fetch('/builder/api/auth/magic', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email }) }); var data = await res.json(); if (!res.ok) { m.className = 'auth-msg error'; m.textContent = data.error || 'Something went wrong.'; } else if (data.devLink) { m.className = 'auth-msg success'; m.innerHTML = 'Dev mode — click here to sign in.'; } else { m.className = 'auth-msg success'; m.textContent = 'Check your email for the sign-in link.'; } } catch (err) { m.className = 'auth-msg error'; m.textContent = 'Network error.'; } finally { btn.disabled = false; btn.textContent = 'Send magic link'; } }); } } function renderSiteDropdown() { siteDropdown.innerHTML = ''; if (!currentUser) { var h = document.createElement('div'); h.className = 'dropdown-header'; h.textContent = 'Sign in to save'; siteDropdown.appendChild(h); return; } var header = document.createElement('div'); header.className = 'dropdown-header'; header.textContent = 'Your sites'; siteDropdown.appendChild(header); if (sitesList.length === 0) { var empty = document.createElement('div'); empty.className = 'dropdown-item'; empty.style.color = '#64748b'; empty.textContent = 'No saved sites yet'; siteDropdown.appendChild(empty); } else { sitesList.forEach(function(s){ var b = document.createElement('button'); b.className = 'dropdown-item' + (currentSite && currentSite.id === s.id ? ' active' : ''); var d = new Date(s.updated_at * 1000); b.innerHTML = '
' + escapeHTML(s.name) + '
' + 'Updated ' + d.toLocaleString() + ''; b.addEventListener('click', function(){ siteDropdown.classList.remove('open'); loadSite(s.id); }); siteDropdown.appendChild(b); }); } var sep = document.createElement('div'); sep.className = 'dropdown-sep'; siteDropdown.appendChild(sep); var nb = document.createElement('button'); nb.className = 'dropdown-item'; nb.textContent = '+ New site'; nb.addEventListener('click', function(){ siteDropdown.classList.remove('open'); newSite(); }); siteDropdown.appendChild(nb); } function setSiteLabel() { siteBtnLabel.textContent = currentSite ? currentSite.name : 'Untitled site'; } async function loadMe() { try { var res = await fetch('/builder/api/me'); if (!res.ok) return; var data = await res.json(); currentUser = data.user; sitesList = data.sites || []; renderAuthUI(); renderSiteDropdown(); } catch (e) { console.error('loadMe failed', e); } } async function loadSite(id) { try { var res = await fetch('/builder/api/sites/' + encodeURIComponent(id)); if (!res.ok) { setSaveStatus('error', 'Could not load site'); return; } var data = await res.json(); if (data.site && data.site.state) { state = data.site.state; currentSite = { id: data.site.id, name: data.site.name, updated_at: data.site.updated_at, slug: data.site.slug || null, published: !!data.site.published }; history = []; messagesEl.innerHTML = ''; addMessage('Loaded "' + currentSite.name + '". Tell me what to change.', 'assistant'); renderPreview(); setSiteLabel(); renderSiteDropdown(); updatePublishLabel(); var url = new URL(window.location.href); url.searchParams.set('site', currentSite.id); window.history.replaceState({}, '', url); } } catch (e) { setSaveStatus('error', 'Load failed'); } } function newSite() { state = defaultState(); currentSite = null; history = []; messagesEl.innerHTML = ''; setSiteLabel(); renderPreview(); var url = new URL(window.location.href); url.searchParams.delete('site'); window.history.replaceState({}, '', url); renderSiteDropdown(); welcome(); } async function ensureSite(firstName) { if (currentSite) return currentSite; if (!currentUser) return null; var name = firstName || (state && state.name) || 'Untitled site'; var res = await fetch('/builder/api/sites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name, state: state }) }); if (!res.ok) return null; var data = await res.json(); currentSite = { id: data.id, name: data.name, updated_at: data.updated_at, slug: null, published: false }; sitesList.unshift({ id: data.id, name: data.name, updated_at: data.updated_at }); setSiteLabel(); renderSiteDropdown(); var url = new URL(window.location.href); url.searchParams.set('site', currentSite.id); window.history.replaceState({}, '', url); return currentSite; } async function saveSoon() { if (!currentUser) return; clearTimeout(saveTimer); saveTimer = setTimeout(doSave, 600); } async function doSave() { if (!currentUser) return; setSaveStatus('saving', 'Saving…'); try { var site = await ensureSite(state && state.name); if (!site) { setSaveStatus('error', 'Save failed'); return; } var res = await fetch('/builder/api/sites/' + encodeURIComponent(site.id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: state && state.name, state: state }) }); if (!res.ok) { setSaveStatus('error', 'Save failed'); return; } var data = await res.json(); currentSite.updated_at = data.updated_at; currentSite.name = state && state.name ? state.name : currentSite.name; setSiteLabel(); setSaveStatus('saved', 'Saved'); setTimeout(function(){ if (saveStatus.classList.contains('saved')) setSaveStatus('', ''); }, 2000); } catch (e) { setSaveStatus('error', 'Save failed'); } } // --- Publishing --- function updatePublishLabel() { if (currentSite && currentSite.published) { publishBtnLabel.textContent = 'Published'; publishBtn.style.background = 'rgba(34,197,94,0.1)'; publishBtn.style.borderColor = 'rgba(34,197,94,0.25)'; publishBtn.style.color = '#86efac'; } else { publishBtnLabel.textContent = 'Publish'; publishBtn.style.background = ''; publishBtn.style.borderColor = ''; publishBtn.style.color = ''; } } function defaultSlug() { if (currentSite && currentSite.slug) return currentSite.slug; var n = (state && state.name) ? state.name : 'my-site'; return String(n).toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 60) || 'my-site'; } async function renderPublishDropdown() { publishDropdown.innerHTML = ''; if (!currentUser) { var h = document.createElement('div'); h.className = 'dropdown-header'; h.textContent = 'Sign in to publish'; publishDropdown.appendChild(h); return; } if (!currentSite) { // Force a save to create the site row await ensureSite(state && state.name); } if (!currentSite) { var err = document.createElement('div'); err.className = 'dropdown-header'; err.textContent = 'Save failed — try again'; publishDropdown.appendChild(err); return; } var header = document.createElement('div'); header.className = 'dropdown-header'; header.textContent = currentSite.published ? 'Live site' : 'Publish site'; publishDropdown.appendChild(header); var form = document.createElement('div'); form.className = 'auth-form'; form.innerHTML = '
' + '' + '.emforge.io' + '
' + '
' + (currentSite.published ? '' + '' + '
Live at ' + escapeHTML(currentSite.slug || '') + '.emforge.io
' : ''); publishDropdown.appendChild(form); var slugInput = document.getElementById('slugInput'); var slugStatus = document.getElementById('slugStatus'); var checkTimer = null; function checkSlug() { clearTimeout(checkTimer); checkTimer = setTimeout(async function(){ var s = slugInput.value.trim(); if (!s) { slugStatus.className = 'auth-msg'; slugStatus.textContent = ''; return; } if (currentSite.slug && s === currentSite.slug) { slugStatus.className = 'auth-msg success'; slugStatus.textContent = 'Current slug'; return; } slugStatus.className = 'auth-msg'; slugStatus.textContent = 'Checking…'; try { var res = await fetch('/builder/api/slug-check?slug=' + encodeURIComponent(s)); var d = await res.json(); if (d.available) { slugStatus.className = 'auth-msg success'; slugStatus.textContent = 'Available'; } else { slugStatus.className = 'auth-msg error'; slugStatus.textContent = d.reason || 'Not available'; } } catch (e) { slugStatus.className = 'auth-msg error'; slugStatus.textContent = 'Check failed'; } }, 300); } slugInput.addEventListener('input', checkSlug); checkSlug(); async function doPublish(useSlug) { // Ensure latest state is saved before we publish. await doSave(); var slug = useSlug || slugInput.value.trim(); var btn = document.getElementById('pubBtn') || document.getElementById('updatePubBtn'); if (btn) { btn.disabled = true; btn.textContent = 'Publishing…'; } try { var res = await fetch('/builder/api/sites/' + encodeURIComponent(currentSite.id) + '/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug: slug }) }); var d = await res.json(); if (!res.ok) { slugStatus.className = 'auth-msg error'; slugStatus.textContent = d.error || 'Publish failed'; if (btn) { btn.disabled = false; btn.textContent = 'Publish site'; } return; } currentSite.slug = d.slug; currentSite.published = true; updatePublishLabel(); renderPublishDropdown(); } catch (e) { slugStatus.className = 'auth-msg error'; slugStatus.textContent = 'Network error'; if (btn) { btn.disabled = false; btn.textContent = 'Publish site'; } } } var pubBtn = document.getElementById('pubBtn'); if (pubBtn) pubBtn.addEventListener('click', function(){ doPublish(); }); var updateBtn = document.getElementById('updatePubBtn'); if (updateBtn) updateBtn.addEventListener('click', function(){ doPublish(); }); var unpubBtn = document.getElementById('unpubBtn'); if (unpubBtn) unpubBtn.addEventListener('click', async function(){ unpubBtn.disabled = true; unpubBtn.textContent = 'Unpublishing…'; try { var res = await fetch('/builder/api/sites/' + encodeURIComponent(currentSite.id) + '/unpublish', { method: 'POST' }); if (!res.ok) { unpubBtn.disabled = false; unpubBtn.textContent = 'Unpublish'; return; } currentSite.published = false; updatePublishLabel(); renderPublishDropdown(); } catch (e) { unpubBtn.disabled = false; unpubBtn.textContent = 'Unpublish'; } }); } async function signOut() { try { await fetch('/builder/api/auth/logout', { method: 'POST' }); } catch (e) {} currentUser = null; sitesList = []; currentSite = null; setSiteLabel(); renderAuthUI(); renderSiteDropdown(); authDropdown.classList.remove('open'); } async function uploadFile(file) { var id = Math.random().toString(36).slice(2); var entry = { id: id, name: file.name, url: null, previewDataUrl: null, status: 'uploading' }; var reader = new FileReader(); reader.onload = function(){ entry.previewDataUrl = reader.result; renderAttachments(); }; reader.readAsDataURL(file); pendingAttachments.push(entry); renderAttachments(); try { var form = new FormData(); form.append('image', file); var res = await fetch('/builder/api/upload', { method: 'POST', body: form }); if (!res.ok) { var err = await res.json().catch(function(){ return { error: 'HTTP ' + res.status }; }); entry.status = 'error'; entry.name = file.name + ' — ' + (err.error || 'upload failed'); renderAttachments(); return; } var data = await res.json(); entry.url = data.url; entry.status = 'ready'; renderAttachments(); } catch (e) { entry.status = 'error'; entry.name = file.name + ' — network error'; renderAttachments(); } } // --- Send message --- async function sendMessage() { var text = input.value.trim(); var readyUploads = pendingAttachments.filter(function(a){ return a.status === 'ready' && a.url; }); if ((!text && readyUploads.length === 0) || isThinking) return; if (pendingAttachments.some(function(a){ return a.status === 'uploading'; })) return; var prefix = readyUploads.map(function(a){ return '[user uploaded image: ' + a.url + ']'; }).join(' '); var outbound = prefix ? (prefix + (text ? ' ' + text : '')) : text; var display = text || '(sent ' + readyUploads.length + ' image' + (readyUploads.length === 1 ? '' : 's') + ')'; addMessage(display, 'user'); input.value = ''; input.style.height = 'auto'; pendingAttachments = []; renderAttachments(); showTyping(); sendBtn.disabled = true; try { var res = await fetch('/builder/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ history: history, userMessage: outbound, state: state }) }); hideTyping(); sendBtn.disabled = false; if (!res.ok) { var errBody = await res.json().catch(function(){ return { error: 'HTTP ' + res.status }; }); addMessage('Error: ' + (errBody.error || 'Unknown error'), 'error'); return; } var data = await res.json(); if (!data.reply || !data.state) { addMessage('Error: malformed response from server', 'error'); return; } history.push({ role: 'user', content: outbound }); history.push({ role: 'assistant', content: data.reply }); state = data.state; addMessage(data.reply, 'assistant'); renderPreview(); saveSoon(); } catch (err) { hideTyping(); sendBtn.disabled = false; addMessage('Error: ' + (err && err.message ? err.message : 'Network error'), 'error'); } } // --- Event listeners --- sendBtn.addEventListener('click', sendMessage); attachBtn.addEventListener('click', function(){ fileInput.click(); }); fileInput.addEventListener('change', function(e){ var files = e.target.files ? Array.from(e.target.files) : []; files.forEach(uploadFile); e.target.value = ''; }); input.addEventListener('paste', function(e){ if (!e.clipboardData) return; var items = Array.from(e.clipboardData.items); var images = items.filter(function(it){ return it.kind === 'file' && it.type.startsWith('image/'); }); if (images.length) { e.preventDefault(); images.forEach(function(it){ var f = it.getAsFile(); if (f) uploadFile(f); }); } }); input.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); input.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 120) + 'px'; }); deviceBtns.forEach(function(btn) { btn.addEventListener('click', function() { deviceBtns.forEach(function(b) { b.classList.remove('active'); }); btn.classList.add('active'); var device = btn.getAttribute('data-device'); previewFrame.className = 'preview-frame'; if (device === 'tablet') previewFrame.classList.add('tablet'); if (device === 'mobile') previewFrame.classList.add('mobile'); }); }); refreshBtn.addEventListener('click', renderPreview); tabs.forEach(function(tab) { tab.addEventListener('click', function() { tabs.forEach(function(t) { t.classList.remove('active'); }); tab.classList.add('active'); var target = tab.getAttribute('data-tab'); if (target === 'chat') { chatPanel.classList.remove('hidden'); previewPanel.classList.remove('visible'); } else { chatPanel.classList.add('hidden'); previewPanel.classList.add('visible'); } }); }); // --- Dropdowns --- function toggleDropdown(dd) { var wasOpen = dd.classList.contains('open'); siteDropdown.classList.remove('open'); authDropdown.classList.remove('open'); publishDropdown.classList.remove('open'); if (!wasOpen) dd.classList.add('open'); } siteBtn.addEventListener('click', function(e){ e.stopPropagation(); toggleDropdown(siteDropdown); }); authBtn.addEventListener('click', function(e){ e.stopPropagation(); toggleDropdown(authDropdown); }); publishBtn.addEventListener('click', async function(e){ e.stopPropagation(); toggleDropdown(publishDropdown); if (publishDropdown.classList.contains('open')) await renderPublishDropdown(); }); document.addEventListener('click', function(e){ if (!siteDropdown.contains(e.target) && !siteBtn.contains(e.target)) siteDropdown.classList.remove('open'); if (!authDropdown.contains(e.target) && !authBtn.contains(e.target)) authDropdown.classList.remove('open'); if (!publishDropdown.contains(e.target) && !publishBtn.contains(e.target)) publishDropdown.classList.remove('open'); }); function welcome() { showTyping(); setTimeout(function() { hideTyping(); addMessage("Welcome to EmForge Builder. Tell me what kind of website you want — a bakery, a SaaS landing page, a portfolio, anything. You can also just describe changes like \"make it dark\" or \"change the hero to say X\" and I will update the preview live.", 'assistant'); input.focus(); }, 600); } // --- Initial boot --- (async function init(){ renderAuthUI(); renderSiteDropdown(); renderPreview(); await loadMe(); var params = new URLSearchParams(window.location.search); var siteId = params.get('site'); if (siteId && currentUser) { await loadSite(siteId); } else { welcome(); } })(); })();