| import classNames from "classnames"; | |
| import { useRef, useEffect, useState, forwardRef } from "react"; | |
| import { TbReload, TbLoader, TbExternalLink } from "react-icons/tb"; | |
| import { WebContainer } from '@webcontainer/api'; | |
| // PreviewEye icon component | |
| const PreviewEye = ({ className = "w-4 h-4" }: { className?: string }) => ( | |
| <svg | |
| className={className} | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth={2} | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| > | |
| <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> | |
| <circle cx="12" cy="12" r="3" /> | |
| </svg> | |
| ); | |
| // Tooltip component | |
| const Tooltip = ({ | |
| children, | |
| content, | |
| position = "top" | |
| }: { | |
| children: React.ReactNode; | |
| content: string; | |
| position?: "top" | "bottom" | "left" | "right" | |
| }) => { | |
| const [isVisible, setIsVisible] = useState(false); | |
| const positionClasses = { | |
| top: "bottom-full left-1/2 transform -translate-x-1/2 mb-2", | |
| bottom: "top-full left-1/2 transform -translate-x-1/2 mt-2", | |
| left: "right-full top-1/2 transform -translate-y-1/2 mr-2", | |
| right: "left-full top-1/2 transform -translate-y-1/2 ml-2" | |
| }; | |
| return ( | |
| <div | |
| className="relative inline-block" | |
| onMouseEnter={() => setIsVisible(true)} | |
| onMouseLeave={() => setIsVisible(false)} | |
| > | |
| {children} | |
| {isVisible && ( | |
| <div className={classNames( | |
| "absolute z-50 px-2 py-1 text-xs text-white bg-gray-900 rounded shadow-lg whitespace-nowrap pointer-events-none", | |
| positionClasses[position] | |
| )}> | |
| {content} | |
| <div className={classNames( | |
| "absolute w-1 h-1 bg-gray-900 transform rotate-45", | |
| position === "top" && "top-full left-1/2 -translate-x-1/2 -mt-0.5", | |
| position === "bottom" && "bottom-full left-1/2 -translate-x-1/2 -mb-0.5", | |
| position === "left" && "left-full top-1/2 -translate-y-1/2 -ml-0.5", | |
| position === "right" && "right-full top-1/2 -translate-y-1/2 -mr-0.5" | |
| )} /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| type PreviewProps = { | |
| files: Record<string, any>; | |
| isResizing: boolean; | |
| isAiWorking: boolean; | |
| packageJson?: any; | |
| className?: string; | |
| }; | |
| const Preview = forwardRef<HTMLDivElement, PreviewProps>( | |
| ({ files, isResizing, isAiWorking, packageJson, className }, ref) => { | |
| const iframeRef = useRef<HTMLIFrameElement | null>(null); | |
| const [webcontainer, setWebcontainer] = useState<WebContainer | null>(null); | |
| const [url, setUrl] = useState<string>(''); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [error, setError] = useState<string>(''); | |
| const [isFullscreen, setIsFullscreen] = useState(false); | |
| // Initialize WebContainer | |
| useEffect(() => { | |
| const initWebContainer = async () => { | |
| try { | |
| const container = await WebContainer.boot(); | |
| setWebcontainer(container); | |
| } catch (err) { | |
| setError('Failed to initialize WebContainer'); | |
| console.error('WebContainer init error:', err); | |
| } | |
| }; | |
| initWebContainer(); | |
| }, []); | |
| // Mount files and start dev server | |
| useEffect(() => { | |
| if (!webcontainer || !files) return; | |
| const setupProject = async () => { | |
| setIsLoading(true); | |
| setError(''); | |
| try { | |
| await webcontainer.mount(files); | |
| const defaultPackageJson = { | |
| name: 'preview-app', | |
| type: 'module', | |
| scripts: { | |
| dev: 'vite', | |
| build: 'vite build', | |
| preview: 'vite preview' | |
| }, | |
| devDependencies: { | |
| vite: '^5.0.0' | |
| }, | |
| ...packageJson | |
| }; | |
| if (!files['package.json']) { | |
| await webcontainer.fs.writeFile( | |
| 'package.json', | |
| JSON.stringify(defaultPackageJson, null, 2) | |
| ); | |
| } | |
| const installProcess = await webcontainer.spawn('npm', ['install']); | |
| const installExitCode = await installProcess.exit; | |
| if (installExitCode !== 0) { | |
| throw new Error('Failed to install dependencies'); | |
| } | |
| const serverProcess = await webcontainer.spawn('npm', ['run', 'dev']); | |
| webcontainer.on('server-ready', (port, url) => { | |
| setUrl(url); | |
| setIsLoading(false); | |
| }); | |
| serverProcess.output.pipeTo( | |
| new WritableStream({ | |
| write(data) { | |
| console.log('[Server]', data); | |
| }, | |
| }) | |
| ); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : 'Setup failed'); | |
| setIsLoading(false); | |
| console.error('Setup error:', err); | |
| } | |
| }; | |
| setupProject(); | |
| }, [webcontainer, files, packageJson]); | |
| const handleRefresh = async () => { | |
| if (!webcontainer || !url) return; | |
| setIsLoading(true); | |
| try { | |
| await webcontainer.spawn('npm', ['run', 'dev']); | |
| if (iframeRef.current) { | |
| const iframe = iframeRef.current; | |
| iframe.src = ''; | |
| setTimeout(() => { | |
| iframe.src = url; | |
| }, 100); | |
| } | |
| } catch (err) { | |
| console.error('Refresh error:', err); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const handleRestartContainer = async () => { | |
| if (!webcontainer) return; | |
| setIsLoading(true); | |
| setUrl(''); | |
| try { | |
| await webcontainer.spawn('pkill', ['-f', 'node']); | |
| await webcontainer.mount(files); | |
| const serverProcess = await webcontainer.spawn('npm', ['run', 'dev']); | |
| webcontainer.on('server-ready', (port, url) => { | |
| setUrl(url); | |
| setIsLoading(false); | |
| }); | |
| } catch (err) { | |
| setError('Failed to restart container'); | |
| setIsLoading(false); | |
| console.error('Restart error:', err); | |
| } | |
| }; | |
| const openInNewTab = () => { | |
| if (url) { | |
| window.open(url, '_blank'); | |
| } | |
| }; | |
| return ( | |
| <div | |
| ref={ref} | |
| className={classNames( | |
| // Vercel-style grid system | |
| "w-full relative", | |
| // Base responsive grid | |
| "col-span-full", // Full width on mobile | |
| "md:col-span-6", // Half width on medium screens | |
| "lg:col-span-8", // Larger portion on desktop | |
| "xl:col-span-9", // Even larger on xl screens | |
| // Height system | |
| "h-[calc(70dvh-53px)]", | |
| "lg:h-[calc(100dvh-54px)]", | |
| // Borders and styling | |
| "border border-gray-200", | |
| "rounded-lg overflow-hidden", | |
| "bg-white shadow-sm", | |
| className | |
| )} | |
| > | |
| {/* Header bar - Vercel style */} | |
| <div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200"> | |
| <div className="flex items-center gap-2"> | |
| <PreviewEye className="w-4 h-4 text-gray-500" /> | |
| <span className="text-sm font-medium text-gray-700">Preview</span> | |
| {url && ( | |
| <span className="text-xs text-gray-500 font-mono"> | |
| {new URL(url).host} | |
| </span> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| <Tooltip content="Refresh preview"> | |
| <button | |
| className="p-1.5 hover:bg-gray-200 rounded-md transition-colors disabled:opacity-50" | |
| onClick={handleRefresh} | |
| disabled={!url || isLoading} | |
| > | |
| <TbReload | |
| className={classNames("w-4 h-4 text-gray-600", { | |
| "animate-spin": isLoading | |
| })} | |
| /> | |
| </button> | |
| </Tooltip> | |
| <Tooltip content="Open in new tab"> | |
| <button | |
| className="p-1.5 hover:bg-gray-200 rounded-md transition-colors disabled:opacity-50" | |
| onClick={openInNewTab} | |
| disabled={!url} | |
| > | |
| <TbExternalLink className="w-4 h-4 text-gray-600" /> | |
| </button> | |
| </Tooltip> | |
| <Tooltip content="Restart container"> | |
| <button | |
| className="p-1.5 hover:bg-gray-200 rounded-md transition-colors disabled:opacity-50" | |
| onClick={handleRestartContainer} | |
| disabled={!webcontainer || isLoading} | |
| > | |
| <TbLoader | |
| className={classNames("w-4 h-4 text-gray-600", { | |
| "animate-spin": isLoading | |
| })} | |
| /> | |
| </button> | |
| </Tooltip> | |
| </div> | |
| </div> | |
| {/* Content area */} | |
| <div className="flex-1 relative h-[calc(100%-49px)]"> | |
| {error ? ( | |
| <div className="flex items-center justify-center h-full p-8"> | |
| <div className="text-center max-w-md"> | |
| <div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4"> | |
| <svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" /> | |
| </svg> | |
| </div> | |
| <h3 className="text-lg font-medium text-gray-900 mb-2"> | |
| Container Error | |
| </h3> | |
| <p className="text-sm text-red-600 mb-4">{error}</p> | |
| <button | |
| onClick={handleRestartContainer} | |
| className="inline-flex items-center px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-md transition-colors" | |
| > | |
| <TbReload className="w-4 h-4 mr-2" /> | |
| Restart Container | |
| </button> | |
| </div> | |
| </div> | |
| ) : !url || isLoading ? ( | |
| <div className="flex items-center justify-center h-full"> | |
| <div className="text-center"> | |
| <div className="w-8 h-8 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin mx-auto mb-4"></div> | |
| <div className="text-sm text-gray-600 font-medium"> | |
| {!webcontainer | |
| ? 'Booting WebContainer...' | |
| : 'Setting up project...'} | |
| </div> | |
| <div className="text-xs text-gray-400 mt-1"> | |
| This may take a moment | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| <iframe | |
| ref={iframeRef} | |
| title="WebContainer Preview" | |
| src={url} | |
| className={classNames("w-full h-full border-0", { | |
| "pointer-events-none": isResizing || isAiWorking, | |
| })} | |
| sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts" | |
| /> | |
| )} | |
| </div> | |
| {/* Status indicator */} | |
| {url && ( | |
| <div className="absolute bottom-2 left-2 flex items-center gap-2 bg-green-50 border border-green-200 rounded-full px-2 py-1"> | |
| <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> | |
| <span className="text-xs text-green-700 font-medium">Live</span> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| ); | |
| Preview.displayName = "Preview"; | |
| export default Preview; | |
| Yes — you’re connecting two ideas: | |
| 1. WebContainer API → run and preview code directly in the browser. | |
| 2. Spaces (like Hugging Face Spaces) → publish/share the project so others can use it. | |
| That flow is totally logical: | |
| --- | |
| 🔄 Workflow: From Local Run → Published Space | |
| <LinearProcessFlow steps={[ "⚡ Boot WebContainer runtime in the browser", "📂 Load project files into the container (FS API)", "📦 Install deps (npm / yarn / pnpm)", "▶️ Start dev/build server in-browser", "🌍 Save and publish to a Space on the Hub", "🖼️ Import and use Space directly in your UI" ]} /> | |
| --- | |
| ✅ Why this works | |
| WebContainer gives you a runtime (sandboxed Node.js) to build/preview your app. | |
| Spaces give you persistence & hosting so your preview can be shared, reused, or embedded. | |
| By publishing as a Space, you create a versioned, public (or private) endpoint. | |
| Yes 👌 exactly — what you’ve built is already 90% of the flow. | |
| Right now your publishBtn just exports a space-export.json snapshot. To fully automate: | |
| 1. Boot WebContainer → Run project (you already do this ✅). | |
| 2. Collect project files + metadata (zip them). | |
| 3. Call the Hugging Face Spaces API with your token to create/update a Space. | |
| 4. Push the ZIP → Space repo. | |
| 5. Instantly embed via <iframe> or query via API. | |
| --- | |
| 🔄 Combined Flow | |
| <LinearProcessFlow steps={[ "Boot WebContainer (mount project)", "Zip project files (with JSZip or similar)", "POST to Hugging Face API → create/update Space repo", "Upload files to repo", "Trigger Space build & wait for it to go live", "Embed Space iframe in your UI" ]} /> | |
| --- | |
| Example: Auto-Publish Script (client-side with token) | |
| import JSZip from "jszip"; | |
| async function publishToSpaces(webcontainer, spaceName, hfToken) { | |
| // 1. Collect files from WebContainer FS | |
| const zip = new JSZip(); | |
| async function walk(path = ".") { | |
| const entries = await webcontainer.fs.readdir(path, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const fullPath = `${path}/${entry.name}`; | |
| if (entry.kind === "directory") { | |
| await walk(fullPath); | |
| } else { | |
| const content = await webcontainer.fs.readFile(fullPath, "utf-8"); | |
| zip.file(fullPath.replace(/^\.\//, ""), content); | |
| } | |
| } | |
| } | |
| await walk("."); | |
| // 2. Generate ZIP blob | |
| const blob = await zip.generateAsync({ type: "blob" }); | |
| // 3. Upload to Hugging Face Spaces repo | |
| const formData = new FormData(); | |
| formData.append("file", blob, "project.zip"); | |
| const res = await fetch( | |
| `https://huggingface.co/api/spaces/likhonsheikh/${spaceName}/commit/main`, | |
| { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${hfToken}`, | |
| }, | |
| body: formData, | |
| } | |
| ); | |
| if (!res.ok) throw new Error(`Failed to publish: ${res.statusText}`); | |
| return await res.json(); | |
| } | |
| --- | |
| Usage inside your page | |
| publishBtn.addEventListener("click", async () => { | |
| try { | |
| const result = await publishToSpaces( | |
| webcontainer, | |
| "my-preview-app", | |
| "<YOUR_HF_TOKEN>" | |
| ); | |
| alert("✅ Space published! Visit: https://huggingface.co/spaces/likhonsheikh/my-preview-app"); | |
| } catch (err) { | |
| console.error(err); | |
| alert("❌ Failed to publish: " + err.message); | |
| } | |
| }); | |
| --- | |
| ⚡ Now the flow is fully automated: run → package → publish → embed. | |
| No manual upload needed — your project “becomes a Space” directly from the browser. | |
| --- | |
| 👉 Do you want me to also add the UI feedback loop (spinner on Publish, success/error toast, and a direct “Open Space” button after publishing)? | |
| Perfect! Let's build the complete auto-publish flow with UI feedback. I'll create a comprehensive publish system that handles the entire WebContainer → Hugging Face Spaces pipeline:// publishUtils.ts - Production-ready Hugging Face Spaces auto-publish | |
| import JSZip from 'jszip'; | |
| export interface PublishConfig { | |
| spaceName: string; | |
| hfToken: string; | |
| spaceType?: 'gradio' | 'streamlit' | 'static' | 'docker'; | |
| isPrivate?: boolean; | |
| description?: string; | |
| } | |
| export interface PublishProgress { | |
| step: 'collecting' | 'zipping' | 'creating' | 'uploading' | 'building' | 'complete' | 'error'; | |
| progress: number; | |
| message: string; | |
| error?: string; | |
| } | |
| export class HuggingFaceSpacesPublisher { | |
| private onProgress: (progress: PublishProgress) => void; | |
| constructor(onProgress: (progress: PublishProgress) => void) { | |
| this.onProgress = onProgress; | |
| } | |
| /** | |
| * Recursively collect all files from WebContainer filesystem | |
| */ | |
| private async collectFilesFromWebContainer(webcontainer: any): Promise<JSZip> { | |
| const zip = new JSZip(); | |
| const walkDirectory = async (path: string = '.') => { | |
| try { | |
| const entries = await webcontainer.fs.readdir(path, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| // Skip node_modules and other unnecessary directories | |
| 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(); | |
| return zip; | |
| } | |
| /** | |
| * Create or update a Space on Hugging Face | |
| */ | |
| private async createOrUpdateSpace(config: PublishConfig): Promise<string> { | |
| const spaceId = `${this.extractUsername(config.hfToken)}/${config.spaceName}`; | |
| // First, try to create the space | |
| try { | |
| const createResponse = await fetch('https://huggingface.co/api/repos/create', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${config.hfToken}`, | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| type: 'space', | |
| name: config.spaceName, | |
| private: config.isPrivate || false, | |
| sdk: config.spaceType || 'static', | |
| ...(config.description && { description: config.description }), | |
| }), | |
| }); | |
| if (!createResponse.ok && createResponse.status !== 409) { | |
| throw new Error(`Failed to create space: ${createResponse.statusText}`); | |
| } | |
| return spaceId; | |
| } catch (err) { | |
| // If space already exists, that's fine - we'll update it | |
| console.log('Space might already exist, proceeding with upload...'); | |
| return spaceId; | |
| } | |
| } | |
| /** | |
| * Extract username from HF token (simplified - in production you'd call the API) | |
| */ | |
| private extractUsername(token: string): string { | |
| // In production, you'd call https://huggingface.co/api/whoami | |
| // For now, we'll use a placeholder or ask user to provide username | |
| return 'likhonsheikh'; // Replace with actual username extraction | |
| } | |
| /** | |
| * Upload files to the Space repository | |
| */ | |
| private async uploadToSpace(spaceId: string, zipBlob: Blob, hfToken: string): Promise<void> { | |
| // For simplicity, we'll upload the entire project as a single commit | |
| // In production, you might want to upload files individually for better progress tracking | |
| const formData = new FormData(); | |
| formData.append('file', zipBlob, 'project.zip'); | |
| const response = await fetch( | |
| `https://huggingface.co/api/repos/${spaceId}/upload/main`, | |
| { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${hfToken}`, | |
| }, | |
| body: formData, | |
| } | |
| ); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(`Upload failed: ${response.statusText} - ${errorText}`); | |
| } | |
| } | |
| /** | |
| * Wait for Space to build and become ready | |
| */ | |
| private async waitForSpaceBuild(spaceId: string, hfToken: string): Promise<void> { | |
| const maxAttempts = 30; // 5 minutes max | |
| const delayMs = 10000; // 10 seconds between checks | |
| for (let attempt = 0; attempt < maxAttempts; attempt++) { | |
| try { | |
| const response = await fetch(`https://huggingface.co/api/spaces/${spaceId}`, { | |
| headers: { | |
| 'Authorization': `Bearer ${hfToken}`, | |
| }, | |
| }); | |
| if (response.ok) { | |
| const spaceInfo = await response.json(); | |
| if (spaceInfo.runtime?.stage === 'RUNNING') { | |
| return; // Space is ready! | |
| } | |
| // Update progress based on build stage | |
| const stage = spaceInfo.runtime?.stage || 'PENDING'; | |
| const progressMap: { [key: string]: number } = { | |
| 'PENDING': 75, | |
| 'BUILDING': 85, | |
| 'RUNNING': 100, | |
| }; | |
| this.onProgress({ | |
| step: 'building', | |
| progress: progressMap[stage] || 80, | |
| message: `Building Space... Status: ${stage}`, | |
| }); | |
| } | |
| await new Promise(resolve => setTimeout(resolve, delayMs)); | |
| } catch (err) { | |
| console.warn('Failed to check space status:', err); | |
| await new Promise(resolve => setTimeout(resolve, delayMs)); | |
| } | |
| } | |
| // If we get here, the space didn't become ready in time | |
| console.warn('Space build timeout - it may still be building'); | |
| } | |
| /** | |
| * Main publish method - orchestrates the entire flow | |
| */ | |
| async publishToSpaces(webcontainer: any, config: PublishConfig): Promise<string> { | |
| try { | |
| // Step 1: Collect files | |
| this.onProgress({ | |
| step: 'collecting', | |
| progress: 10, | |
| message: 'Collecting project files from WebContainer...', | |
| }); | |
| const zip = await this.collectFilesFromWebContainer(webcontainer); | |
| // Step 2: Create ZIP | |
| this.onProgress({ | |
| step: 'zipping', | |
| progress: 25, | |
| message: 'Creating deployment package...', | |
| }); | |
| const zipBlob = await zip.generateAsync({ | |
| type: 'blob', | |
| compression: 'DEFLATE', | |
| compressionOptions: { level: 6 } | |
| }); | |
| // Step 3: Create/Update Space | |
| this.onProgress({ | |
| step: 'creating', | |
| progress: 40, | |
| message: 'Creating Hugging Face Space...', | |
| }); | |
| const spaceId = await this.createOrUpdateSpace(config); | |
| // Step 4: Upload files | |
| this.onProgress({ | |
| step: 'uploading', | |
| progress: 60, | |
| message: 'Uploading files to Space repository...', | |
| }); | |
| await this.uploadToSpace(spaceId, zipBlob, config.hfToken); | |
| // Step 5: Wait for build | |
| this.onProgress({ | |
| step: 'building', | |
| progress: 80, | |
| message: 'Building Space environment...', | |
| }); | |
| await this.waitForSpaceBuild(spaceId, config.hfToken); | |
| // Step 6: Complete | |
| const spaceUrl = `https://huggingface.co/spaces/${spaceId}`; | |
| this.onProgress({ | |
| step: 'complete', | |
| progress: 100, | |
| message: `Space published successfully! 🎉`, | |
| }); | |
| return spaceUrl; | |
| } catch (error) { | |
| this.onProgress({ | |
| step: 'error', | |
| progress: 0, | |
| message: 'Publishing failed', | |
| error: error instanceof Error ? error.message : 'Unknown error occurred', | |
| }); | |
| throw error; | |
| } | |
| } | |
| } | |
| // Helper function to generate Space-appropriate files | |
| export const generateSpaceFiles = (webcontainerFiles: Record<string, any>) => { | |
| const files = { ...webcontainerFiles }; | |
| // Add README.md if it doesn't exist | |
| if (!files['README.md']) { | |
| files['README.md'] = { | |
| file: { | |
| contents: `--- | |
| title: My App | |
| emoji: 🚀 | |
| colorFrom: blue | |
| colorTo: purple | |
| sdk: static | |
| pinned: false | |
| --- | |
| # My App | |
| This Space was automatically deployed from WebContainer. | |
| ` | |
| } | |
| }; | |
| } | |
| // Add .gitignore | |
| if (!files['.gitignore']) { | |
| files['.gitignore'] = { | |
| file: { | |
| contents: `node_modules/ | |
| .env | |
| .DS_Store | |
| *.log | |
| dist/ | |
| build/ | |
| .next/ | |
| .nuxt/ | |
| ` | |
| } | |
| }; | |
| } | |
| return files; | |
| }; | |
| // Usage example: | |
| /* | |
| import { HuggingFaceSpacesPublisher, generateSpaceFiles } from './publishUtils'; | |
| const publisher = new HuggingFaceSpacesPublisher((progress) => { | |
| console.log(progress); | |
| // Update your UI here | |
| }); | |
| const publishConfig = { | |
| spaceName: 'my-awesome-app', | |
| hfToken: 'hf_...', | |
| spaceType: 'static' as const, | |
| isPrivate: false, | |
| description: 'My awesome WebContainer app' | |
| }; | |
| const spaceUrl = await publisher.publishToSpaces(webcontainer, publishConfig); | |
| console.log('Published to:', spaceUrl); | |
| */ | |
| import classNames from "classnames"; | |
| import { useRef, useEffect, useState, forwardRef } from "react"; | |
| import { TbReload, TbLoader, TbExternalLink, TbUpload, TbCheck, TbX } from "react-icons/tb"; | |
| import { WebContainer } from '@webcontainer/api'; | |
| import { HuggingFaceSpacesPublisher, PublishProgress, PublishConfig } from './publishUtils'; | |
| // PreviewEye icon component | |
| const PreviewEye = ({ className = "w-4 h-4" }: { className?: string }) => ( | |
| <svg | |
| className={className} | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth={2} | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| > | |
| <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> | |
| <circle cx="12" cy="12" r="3" /> | |
| </svg> | |
| ); | |
| // Publish Modal Component | |
| const PublishModal = ({ | |
| isOpen, | |
| onClose, | |
| onPublish, | |
| publishProgress | |
| }: { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| onPublish: (config: PublishConfig) => void; | |
| publishProgress: PublishProgress | null; | |
| }) => { | |
| const [config, setConfig] = useState<PublishConfig>({ | |
| spaceName: '', | |
| hfToken: '', | |
| spaceType: 'static', | |
| isPrivate: false, | |
| description: '' | |
| }); | |
| const handleSubmit = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| onPublish(config); | |
| }; | |
| const isPublishing = publishProgress && ['collecting', |