|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"/> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> |
|
|
<title>WebContainer Live Preview</title> |
|
|
<link rel="icon" type="image/x-icon" href="data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%233b82f6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E"> |
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet"> |
|
|
<script src="https://unpkg.com/aos@2.3.1/dist/aos.js"></script> |
|
|
<script src="https://unpkg.com/feather-icons"></script> |
|
|
<style> |
|
|
|
|
|
::-webkit-scrollbar { width: 6px; height: 6px; } |
|
|
::-webkit-scrollbar-track { background: transparent; } |
|
|
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } |
|
|
::-webkit-scrollbar-thumb:hover { background: #94a3b8; } |
|
|
|
|
|
@keyframes pulse-dot { |
|
|
0%,100%{transform:scale(1);opacity:1;} |
|
|
50%{transform:scale(1.4);opacity:.8;} |
|
|
} |
|
|
.pulse-dot{animation:pulse-dot 1.5s infinite;} |
|
|
</style> |
|
|
</head> |
|
|
<body class="bg-gray-50 text-gray-900 font-sans antialiased"> |
|
|
|
|
|
|
|
|
<header class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-gray-200"> |
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> |
|
|
<div class="flex items-center justify-between h-14"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<i data-feather="box" class="w-5 h-5 text-blue-600"></i> |
|
|
<h1 class="text-lg font-semibold">WebContainer Preview</h1> |
|
|
</div> |
|
|
<div class="flex items-center gap-3"> |
|
|
<button id="publishBtn" class="hidden text-sm bg-indigo-600 hover:bg-indigo-700 text-white px-3 py-1.5 rounded-md transition items-center gap-2"> |
|
|
<i data-feather="upload-cloud" class="w-4 h-4"></i><span>Publish to Space</span> |
|
|
</button> |
|
|
<div class="flex items-center gap-2 text-sm text-gray-500"> |
|
|
<span id="status">Offline</span> |
|
|
<span class="w-2 h-2 bg-gray-300 rounded-full" id="status-dot"></span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
|
|
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> |
|
|
<section class="grid grid-cols-1 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-9 gap-4"> |
|
|
|
|
|
|
|
|
<aside class="col-span-full md:col-span-6 lg:col-span-8 xl:col-span-3 flex flex-col gap-4" data-aos="fade-right"> |
|
|
|
|
|
<div class="bg-white rounded-xl border border-gray-200 p-4 shadow-sm"> |
|
|
<h2 class="text-sm font-semibold mb-3 flex items-center gap-2"> |
|
|
<i data-feather="upload" class="w-4 h-4 text-indigo-600"></i>Upload Project ZIP |
|
|
</h2> |
|
|
<label class="group relative flex flex-col items-center justify-center w-full h-28 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-indigo-400 hover:bg-indigo-50 transition"> |
|
|
<i data-feather="folder-plus" class="w-8 h-8 text-gray-400 group-hover:text-indigo-600"></i> |
|
|
<span class="mt-2 text-sm text-gray-500 group-hover:text-indigo-600">Click or drop ZIP here</span> |
|
|
<input id="zipInput" type="file" accept=".zip" class="absolute opacity-0 w-full h-full"> |
|
|
</label> |
|
|
<p id="fileName" class="mt-2 text-xs text-gray-400 truncate"></p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="bg-white rounded-xl border border-gray-200 p-4 shadow-sm"> |
|
|
<h2 class="text-sm font-semibold mb-3 flex items-center gap-2"> |
|
|
<i data-feather="play" class="w-4 h-4 text-green-600"></i>Actions |
|
|
</h2> |
|
|
<div class="grid grid-cols-2 gap-2"> |
|
|
<button id="bootBtn" class="flex items-center justify-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition"> |
|
|
<i data-feather="zap"></i><span>Boot</span> |
|
|
</button> |
|
|
<button id="stopBtn" class="flex items-center justify-center gap-2 px-3 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 text-sm rounded-lg transition"> |
|
|
<i data-feather="square"></i><span>Stop</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="bg-gray-900 rounded-xl border border-gray-700 p-4 shadow-sm flex-1 flex flex-col"> |
|
|
<div class="flex items-center justify-between mb-2"> |
|
|
<h2 class="text-sm font-semibold text-gray-200 flex items-center gap-2"> |
|
|
<i data-feather="terminal" class="w-4 h-4 text-green-400"></i>Terminal |
|
|
</h2> |
|
|
<button id="clearTerm" class="text-xs text-gray-400 hover:text-white transition"> |
|
|
Clear |
|
|
</button> |
|
|
</div> |
|
|
<pre id="terminal" class="flex-1 text-xs text-green-400 overflow-auto whitespace-pre-wrap"></pre> |
|
|
</div> |
|
|
</aside> |
|
|
|
|
|
|
|
|
<div id="previewWrapper" class="col-span-full md:col-span-6 lg:col-span-8 xl:col-span-6 h-[calc(70vh-53px)] lg:h-[calc(100vh-54px)] bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col" data-aos="fade-left"> |
|
|
|
|
|
<div class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<i data-feather="eye" class="w-4 h-4 text-gray-500"></i> |
|
|
<span class="text-sm font-medium text-gray-700">Preview</span> |
|
|
<span id="previewHost" class="text-xs text-gray-500 font-mono hidden"></span> |
|
|
</div> |
|
|
<div class="flex items-center gap-1"> |
|
|
<button id="refreshBtn" class="p-1.5 hover:bg-gray-200 rounded-md transition disabled:opacity-50" title="Refresh"> |
|
|
<i data-feather="refresh-cw" class="w-4 h-4 text-gray-600"></i> |
|
|
</button> |
|
|
<button id="openBtn" class="p-1.5 hover:bg-gray-200 rounded-md transition disabled:opacity-50" title="Open in new tab"> |
|
|
<i data-feather="external-link" class="w-4 h-4 text-gray-600"></i> |
|
|
</button> |
|
|
<button id="restartBtn" class="p-1.5 hover:bg-gray-200 rounded-md transition disabled:opacity-50" title="Restart container"> |
|
|
<i data-feather="rotate-ccw" class="w-4 h-4 text-gray-600"></i> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="flex-1 relative"> |
|
|
|
|
|
<div id="loader" class="absolute inset-0 flex items-center justify-center bg-white z-10"> |
|
|
<div class="text-center"> |
|
|
<div class="w-8 h-8 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin mx-auto mb-3"></div> |
|
|
<div class="text-sm text-gray-600 font-medium">Booting WebContainer…</div> |
|
|
<div class="text-xs text-gray-400 mt-1">This may take a moment</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="error" class="absolute inset-0 hidden items-center justify-center bg-white z-10"> |
|
|
<div class="text-center max-w-md"> |
|
|
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4"> |
|
|
<i data-feather="alert-triangle" class="w-6 h-6 text-red-600"></i> |
|
|
</div> |
|
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Container Error</h3> |
|
|
<p id="errorText" class="text-sm text-red-600 mb-4"></p> |
|
|
<button id="retryBtn" class="inline-flex items-center px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-md transition"> |
|
|
<i data-feather="rotate-ccw" class="w-4 h-4 mr-2"></i>Restart Container |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<iframe id="preview" class="w-full h-full border-0" sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"></iframe> |
|
|
|
|
|
|
|
|
<div id="liveIndicator" class="absolute bottom-2 left-2 hidden items-center gap-2 bg-green-50 border border-green-200 rounded-full px-2 py-1"> |
|
|
<span class="w-2 h-2 bg-green-500 rounded-full pulse-dot"></span> |
|
|
<span class="text-xs text-green-700 font-medium">Live</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
</main> |
|
|
|
|
|
<script type="module"> |
|
|
|
|
|
feather.replace(); |
|
|
|
|
|
|
|
|
const zipInput = document.getElementById('zipInput'); |
|
|
const fileName = document.getElementById('fileName'); |
|
|
const bootBtn = document.getElementById('bootBtn'); |
|
|
const stopBtn = document.getElementById('stopBtn'); |
|
|
const clearTerm = document.getElementById('clearTerm'); |
|
|
const terminal = document.getElementById('terminal'); |
|
|
const status = document.getElementById('status'); |
|
|
const statusDot = document.getElementById('status-dot'); |
|
|
const loader = document.getElementById('loader'); |
|
|
const error = document.getElementById('error'); |
|
|
const errorText = document.getElementById('errorText'); |
|
|
const retryBtn = document.getElementById('retryBtn'); |
|
|
const refreshBtn = document.getElementById('refreshBtn'); |
|
|
const openBtn = document.getElementById('openBtn'); |
|
|
const restartBtn = document.getElementById('restartBtn'); |
|
|
const preview = document.getElementById('preview'); |
|
|
const previewHost = document.getElementById('previewHost'); |
|
|
const liveIndicator = document.getElementById('liveIndicator'); |
|
|
const publishBtn = document.getElementById('publishBtn'); |
|
|
|
|
|
let webcontainer = null; |
|
|
let url = ''; |
|
|
|
|
|
|
|
|
const log = (msg, type = 'info') => { |
|
|
const stamp = `[${new Date().toLocaleTimeString()}]`; |
|
|
const color = type === 'error' ? 'text-red-400' : type === 'warn' ? 'text-yellow-400' : 'text-green-400'; |
|
|
terminal.insertAdjacentHTML('beforeend', `<div><span class="${color}">${stamp}</span> ${msg}</div>`); |
|
|
terminal.scrollTop = terminal.scrollHeight; |
|
|
}; |
|
|
|
|
|
const setStatus = (state) => { |
|
|
const labels = { offline: 'Offline', booting: 'Booting', online: 'Online', error: 'Error' }; |
|
|
const colors = { |
|
|
offline: 'bg-gray-300', |
|
|
booting: 'bg-yellow-400', |
|
|
online: 'bg-green-500', |
|
|
error: 'bg-red-500' |
|
|
}; |
|
|
status.textContent = labels[state]; |
|
|
statusDot.className = `w-2 h-2 rounded-full ${colors[state]}`; |
|
|
}; |
|
|
|
|
|
const showLoader = () => { |
|
|
loader.classList.remove('hidden'); |
|
|
loader.classList.add('flex'); |
|
|
error.classList.add('hidden'); |
|
|
error.classList.remove('flex'); |
|
|
}; |
|
|
|
|
|
const hideLoader = () => { |
|
|
loader.classList.add('hidden'); |
|
|
loader.classList.remove('flex'); |
|
|
}; |
|
|
|
|
|
const showError = (msg) => { |
|
|
hideLoader(); |
|
|
errorText.textContent = msg; |
|
|
error.classList.remove('hidden'); |
|
|
error.classList.add('flex'); |
|
|
}; |
|
|
|
|
|
const hideError = () => { |
|
|
error.classList.add('hidden'); |
|
|
error.classList.remove('flex'); |
|
|
}; |
|
|
|
|
|
const setLive = (u) => { |
|
|
url = u; |
|
|
preview.src = url; |
|
|
previewHost.textContent = new URL(url).host; |
|
|
previewHost.classList.remove('hidden'); |
|
|
liveIndicator.classList.remove('hidden'); |
|
|
setStatus('online'); |
|
|
publishBtn.classList.remove('hidden'); |
|
|
publishBtn.classList.add('inline-flex'); |
|
|
}; |
|
|
|
|
|
|
|
|
zipInput.addEventListener('change', (e) => { |
|
|
const f = e.target.files[0]; |
|
|
if (f) fileName.textContent = f.name; |
|
|
}); |
|
|
|
|
|
|
|
|
clearTerm.addEventListener('click', () => terminal.innerHTML = ''); |
|
|
|
|
|
|
|
|
bootBtn.addEventListener('click', async () => { |
|
|
if (!zipInput.files.length) return alert('Please select a ZIP file'); |
|
|
showLoader(); |
|
|
hideError(); |
|
|
setStatus('booting'); |
|
|
log('Booting WebContainer…'); |
|
|
|
|
|
try { |
|
|
const { WebContainer } = await import('@webcontainer/api'); |
|
|
webcontainer = await WebContainer.boot(); |
|
|
log('WebContainer booted'); |
|
|
|
|
|
const zip = zipInput.files[0]; |
|
|
const zipBuffer = await zip.arrayBuffer(); |
|
|
const zipArray = new Uint8Array(zipBuffer); |
|
|
const unzip = (await import('unzipit')).unzip; |
|
|
const { entries } = await unzip(zipArray); |
|
|
|
|
|
const files = {}; |
|
|
for (const [path, entry] of Object.entries(entries)) { |
|
|
if (!entry.isFile) continue; |
|
|
const blob = await entry.blob(); |
|
|
files[path] = { file: { blob } }; |
|
|
} |
|
|
|
|
|
await webcontainer.mount(files); |
|
|
log('Files mounted'); |
|
|
|
|
|
|
|
|
const defaultPackageJson = { |
|
|
name: 'preview-app', |
|
|
type: 'module', |
|
|
scripts: { dev: 'vite', build: 'vite build', preview: 'vite preview' }, |
|
|
devDependencies: { vite: '^5.0.0' } |
|
|
}; |
|
|
if (!files['package.json']) { |
|
|
await webcontainer.fs.writeFile('package.json', JSON.stringify(defaultPackageJson, null, 2)); |
|
|
} |
|
|
|
|
|
log('Installing dependencies…'); |
|
|
const install = await webcontainer.spawn('npm', ['install']); |
|
|
await install.exit; |
|
|
log('Dependencies installed'); |
|
|
|
|
|
log('Starting dev server…'); |
|
|
await webcontainer.spawn('npm', ['run', 'dev']); |
|
|
|
|
|
webcontainer.on('server-ready', (port, u) => { |
|
|
hideLoader(); |
|
|
setLive(u); |
|
|
log(`Server ready at ${u}`); |
|
|
}); |
|
|
|
|
|
} catch (err) { |
|
|
log(err.message, 'error'); |
|
|
setStatus('error'); |
|
|
showError(err.message); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
stopBtn.addEventListener('click', async () => { |
|
|
if (!webcontainer) return; |
|
|
try { |
|
|
await webcontainer.spawn('pkill', ['-f', 'node']); |
|
|
webcontainer = null; |
|
|
url = ''; |
|
|
preview.src = ''; |
|
|
previewHost.textContent = ''; |
|
|
previewHost.classList.add('hidden'); |
|
|
liveIndicator.classList.add('hidden'); |
|
|
setStatus('offline'); |
|
|
log('Container stopped'); |
|
|
} catch (e) { |
|
|
log(e.message, 'error'); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
refreshBtn.addEventListener('click', () => { |
|
|
if (!url) return; |
|
|
preview.src = ''; |
|
|
setTimeout(() => preview.src = url, 100); |
|
|
log('Preview refreshed'); |
|
|
}); |
|
|
|
|
|
|
|
|
openBtn.addEventListener('click', () => { |
|
|
if (url) window.open(url, '_blank'); |
|
|
}); |
|
|
|
|
|
|
|
|
restartBtn.addEventListener('click', async () => { |
|
|
if (!webcontainer) return; |
|
|
showLoader(); |
|
|
try { |
|
|
await webcontainer.spawn('pkill', ['-f', 'node']); |
|
|
await bootBtn.click(); |
|
|
} catch (e) { |
|
|
log(e.message, 'error'); |
|
|
showError(e.message); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
retryBtn.addEventListener('click', () => bootBtn.click()); |
|
|
|
|
|
|
|
|
publishBtn.addEventListener('click', async () => { |
|
|
if (!url) return; |
|
|
|
|
|
|
|
|
const spaceName = prompt('Enter Space name (lowercase, no spaces):', 'webcontainer-preview'); |
|
|
if (!spaceName) return; |
|
|
|
|
|
const hfToken = prompt('Enter Hugging Face token (hf_...):'); |
|
|
if (!hfToken) return; |
|
|
|
|
|
publishBtn.disabled = true; |
|
|
publishBtn.innerHTML = '<i data-feather="loader" class="w-4 h-4 animate-spin mr-2"></i>Publishing…'; |
|
|
feather.replace(); |
|
|
|
|
|
try { |
|
|
|
|
|
const JSZip = await import('https://cdn.skypack.dev/jszip'); |
|
|
|
|
|
|
|
|
const zip = new JSZip.default(); |
|
|
|
|
|
|
|
|
const walkDirectory = async (path = '.') => { |
|
|
try { |
|
|
const entries = await webcontainer.fs.readdir(path, { withFileTypes: true }); |
|
|
|
|
|
for (const entry of entries) { |
|
|
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('.')) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
const fullPath = path === '.' ? entry.name : `${path}/${entry.name}`; |
|
|
|
|
|
if (entry.isDirectory()) { |
|
|
await walkDirectory(fullPath); |
|
|
} else { |
|
|
try { |
|
|
const content = await webcontainer.fs.readFile(fullPath, 'utf-8'); |
|
|
zip.file(fullPath, content); |
|
|
} catch (err) { |
|
|
|
|
|
try { |
|
|
const content = await webcontainer.fs.readFile(fullPath); |
|
|
zip.file(fullPath, content); |
|
|
} catch (binaryErr) { |
|
|
console.warn(`Failed to read file: ${fullPath}`, binaryErr); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} catch (err) { |
|
|
console.error(`Failed to read directory: ${path}`, err); |
|
|
} |
|
|
}; |
|
|
|
|
|
await walkDirectory(); |
|
|
|
|
|
|
|
|
const readmeContent = `--- |
|
|
title: ${spaceName} |
|
|
emoji: 🚀 |
|
|
colorFrom: blue |
|
|
colorTo: purple |
|
|
sdk: static |
|
|
pinned: false |
|
|
--- |
|
|
|
|
|
# ${spaceName} |
|
|
|
|
|
This Space was automatically deployed from WebContainer. |
|
|
`; |
|
|
zip.file('README.md', readmeContent); |
|
|
|
|
|
|
|
|
const gitignoreContent = `node_modules/ |
|
|
.env |
|
|
.DS_Store |
|
|
*.log |
|
|
dist/ |
|
|
build/ |
|
|
.next/ |
|
|
.nuxt/ |
|
|
`; |
|
|
zip.file('.gitignore', gitignoreContent); |
|
|
|
|
|
|
|
|
const zipBlob = await zip.generateAsync({ |
|
|
type: 'blob', |
|
|
compression: 'DEFLATE', |
|
|
compressionOptions: { level: 6 } |
|
|
}); |
|
|
|
|
|
|
|
|
const username = 'likhonsheikh'; |
|
|
const spaceId = `${username}/${spaceName}`; |
|
|
|
|
|
|
|
|
try { |
|
|
await fetch('https://huggingface.co/api/repos/create', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Authorization': `Bearer ${hfToken}`, |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
type: 'space', |
|
|
name: spaceName, |
|
|
private: false, |
|
|
sdk: 'static', |
|
|
description: `Auto-published from WebContainer` |
|
|
}), |
|
|
}); |
|
|
} catch (createErr) { |
|
|
|
|
|
console.log('Space might already exist, proceeding with upload...'); |
|
|
} |
|
|
|
|
|
|
|
|
const formData = new FormData(); |
|
|
formData.append('file', zipBlob, 'project.zip'); |
|
|
|
|
|
const uploadResponse = await fetch( |
|
|
`https://huggingface.co/api/repos/${spaceId}/upload/main`, |
|
|
{ |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Authorization': `Bearer ${hfToken}`, |
|
|
}, |
|
|
body: formData, |
|
|
} |
|
|
); |
|
|
|
|
|
if (!uploadResponse.ok) { |
|
|
const errorText = await uploadResponse.text(); |
|
|
throw new Error(`Upload failed: ${uploadResponse.statusText} - ${errorText}`); |
|
|
} |
|
|
|
|
|
|
|
|
log('Space created! Building...'); |
|
|
|
|
|
setTimeout(() => { |
|
|
const spaceUrl = `https://huggingface.co/spaces/${spaceId}`; |
|
|
log(`Space published! View at: ${spaceUrl}`); |
|
|
|
|
|
if (confirm('Space published! Open in new tab?')) { |
|
|
window.open(spaceUrl, '_blank'); |
|
|
} |
|
|
}, 5000); |
|
|
|
|
|
} catch (e) { |
|
|
log(e.message, 'error'); |
|
|
alert('Publishing failed: ' + e.message); |
|
|
} finally { |
|
|
publishBtn.disabled = false; |
|
|
publishBtn.innerHTML = '<i data-feather="upload-cloud" class="w-4 h-4 mr-2"></i>Publish to Space'; |
|
|
feather.replace(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
setStatus('offline'); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|