webcontainer-preview / index.html
HuggingFaceDAO's picture
Yes 👌 exactly — what you’ve built is already 90% of the flow.
df6ab71 verified
<!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>
/* Custom scrollbar */
::-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; }
/* Animated pulse for live dot */
@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 -->
<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 -->
<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">
<!-- Controls -->
<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">
<!-- Upload -->
<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>
<!-- Actions -->
<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>
<!-- Terminal -->
<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>
<!-- Preview -->
<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">
<!-- Preview Header -->
<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>
<!-- Preview Body -->
<div class="flex-1 relative">
<!-- Loading -->
<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>
<!-- Error -->
<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 -->
<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>
<!-- Live indicator -->
<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 Icons ---------- */
feather.replace();
/* ---------- Elements ---------- */
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 = '';
/* ---------- Util ---------- */
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');
};
/* ---------- File Input ---------- */
zipInput.addEventListener('change', (e) => {
const f = e.target.files[0];
if (f) fileName.textContent = f.name;
});
/* ---------- Terminal Clear ---------- */
clearTerm.addEventListener('click', () => terminal.innerHTML = '');
/* ---------- Boot ---------- */
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');
// Default package.json
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);
}
});
/* ---------- Stop ---------- */
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');
}
});
/* ---------- Refresh ---------- */
refreshBtn.addEventListener('click', () => {
if (!url) return;
preview.src = '';
setTimeout(() => preview.src = url, 100);
log('Preview refreshed');
});
/* ---------- Open in new tab ---------- */
openBtn.addEventListener('click', () => {
if (url) window.open(url, '_blank');
});
/* ---------- Restart container ---------- */
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);
}
});
/* ---------- Retry ---------- */
retryBtn.addEventListener('click', () => bootBtn.click());
/* ---------- Publish to Space ---------- */
publishBtn.addEventListener('click', async () => {
if (!url) return;
// Get Space name and token from user
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 {
// Dynamically import JSZip and publish utilities
const JSZip = await import('https://cdn.skypack.dev/jszip');
// Create project snapshot with actual files
const zip = new JSZip.default();
// Collect files from WebContainer filesystem
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 reading as binary if UTF-8 fails
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();
// Add README.md for Spaces
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);
// Add .gitignore
const gitignoreContent = `node_modules/
.env
.DS_Store
*.log
dist/
build/
.next/
.nuxt/
`;
zip.file('.gitignore', gitignoreContent);
// Generate ZIP blob
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
});
// Create Space via Hugging Face API
const username = 'likhonsheikh'; // In production, extract from token
const spaceId = `${username}/${spaceName}`;
// Create or update space
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) {
// Space might already exist, continue with upload
console.log('Space might already exist, proceeding with upload...');
}
// Upload files to Space repository
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}`);
}
// Wait for Space to build (simplified - in production poll the API)
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();
}
});
/* ---------- Init status ---------- */
setStatus('offline');
</script>
</body>
</html>