/** * Asset Management System for Site Builder * Handles upload, storage, browsing, and integration with editor blocks. * * Storage strategy: * - When server API is available (server.py running): files are uploaded to * /storage/assets/ on disk and referenced by URL. No base64 in localStorage. * - When server API is unavailable: falls back to external URL references only. * File uploads (which would produce base64) show an error asking to start the server. */ (function() { 'use strict'; const ASSETS_KEY = 'sitebuilder-assets'; const DEPLOY_STATUS_KEY = 'sitebuilder-deploy-status'; // Server API base URL (same origin) const API_BASE = ''; // ========================================== // Asset Manager // ========================================== class AssetManager { constructor(editor) { this.editor = editor; this.assets = []; this.modal = null; this.resolveSelection = null; this.serverAvailable = false; // Check server availability, then load assets this._checkServer().then(() => { this.load(); this.initUI(); this.initDragDrop(); this.initAssetBrowser(); this.patchImageTraits(); this.patchVideoTraits(); this.patchFileEmbedTraits(); this.initDeployButton(); }); } // -- Server availability check -- async _checkServer() { try { const resp = await fetch(API_BASE + '/api/health', { method: 'GET' }); if (resp.ok) { const data = await resp.json(); this.serverAvailable = data.status === 'ok'; } } catch (e) { this.serverAvailable = false; } } // -- Persistence -- async load() { if (this.serverAvailable) { try { const resp = await fetch(API_BASE + '/api/assets'); if (resp.ok) { const data = await resp.json(); if (data.success && Array.isArray(data.assets)) { this.assets = data.assets; // Also save metadata index to localStorage (no file contents) this._saveMetadataIndex(); this.renderAssetsPanel(); return; } } } catch (e) { console.warn('Failed to load assets from server, using localStorage fallback:', e.message); } } // Fallback: load from localStorage metadata index try { this.assets = JSON.parse(localStorage.getItem(ASSETS_KEY) || '[]'); // Filter out any old base64 data URLs from localStorage to prevent quota issues this.assets = this.assets.filter(a => !a.url || !a.url.startsWith('data:')); } catch(e) { this.assets = []; } this.renderAssetsPanel(); } _saveMetadataIndex() { // Save only metadata (no file contents) to localStorage as a lightweight index try { const metadata = this.assets.map(a => ({ id: a.id, name: a.name, url: a.url, type: a.type, size: a.size, added: a.added })); localStorage.setItem(ASSETS_KEY, JSON.stringify(metadata)); } catch (e) { // If localStorage is full, just skip caching console.warn('Could not save asset metadata to localStorage:', e.message); } } save() { // For server-based storage, save is handled per-operation (upload/delete). // We still maintain a lightweight metadata index in localStorage. this._saveMetadataIndex(); } async addAsset(name, fileOrDataUrl, type, fileSize) { // If it's a File object and server is available, upload to server if (fileOrDataUrl instanceof File) { return this._uploadFileToServer(fileOrDataUrl, type); } // If it's a data URL string and server is available, convert to file upload if (typeof fileOrDataUrl === 'string' && fileOrDataUrl.startsWith('data:') && this.serverAvailable) { try { const blob = await this._dataUrlToBlob(fileOrDataUrl); const file = new File([blob], name, { type: blob.type }); return this._uploadFileToServer(file, type); } catch (e) { console.warn('Failed to convert data URL to file for upload:', e.message); } } // If it's a data URL but server is NOT available, warn the user if (typeof fileOrDataUrl === 'string' && fileOrDataUrl.startsWith('data:') && !this.serverAvailable) { this._showServerRequiredError(); return null; } // For non-data-URL strings (external URLs), store as reference const asset = { id: 'asset_' + Date.now() + '_' + Math.random().toString(36).substr(2,6), name, url: fileOrDataUrl, type, size: fileSize || 0, added: Date.now() }; this.assets.push(asset); this.save(); this.renderAssetsPanel(); return asset; } _dataUrlToBlob(dataUrl) { return new Promise((resolve, reject) => { try { const parts = dataUrl.split(','); const mimeMatch = parts[0].match(/:(.*?);/); const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream'; const byteString = atob(parts[1]); const bytes = new Uint8Array(byteString.length); for (let i = 0; i < byteString.length; i++) { bytes[i] = byteString.charCodeAt(i); } resolve(new Blob([bytes], { type: mime })); } catch (e) { reject(e); } }); } async _uploadFileToServer(file, type) { const formData = new FormData(); formData.append('file', file); try { const resp = await fetch(API_BASE + '/api/assets/upload', { method: 'POST', body: formData }); if (!resp.ok) { const errData = await resp.json().catch(() => ({ error: 'Upload failed' })); throw new Error(errData.error || 'Upload failed with status ' + resp.status); } const data = await resp.json(); if (data.success && data.assets && data.assets.length > 0) { const serverAsset = data.assets[0]; // Add to local list this.assets.push(serverAsset); this.save(); this.renderAssetsPanel(); // Register with GrapesJS asset manager if (serverAsset.type === 'image') { this.editor.AssetManager.add({ src: serverAsset.url, name: serverAsset.name }); } return serverAsset; } throw new Error('No asset returned from server'); } catch (e) { console.error('Asset upload failed:', e.message); this._showUploadError(e.message); return null; } } _showServerRequiredError() { const msg = 'File upload requires the development server (server.py).\n\n' + 'Start it with: python3 server.py\n\n' + 'You can still add assets by pasting external URLs.'; alert(msg); } _showUploadError(message) { alert('Asset upload failed: ' + message + '\n\nPlease check that server.py is running.'); } addAssetUrl(url) { const name = url.split('/').pop().split('?')[0] || 'asset'; const ext = name.split('.').pop().toLowerCase(); let type = 'file'; if (['jpg','jpeg','png','gif','webp','svg','ico'].includes(ext)) type = 'image'; else if (['mp4','webm','ogg','mov'].includes(ext)) type = 'video'; else if (ext === 'css') type = 'css'; else if (ext === 'js') type = 'js'; const asset = { id: 'asset_' + Date.now() + '_' + Math.random().toString(36).substr(2,6), name, url, type, size: 0, added: Date.now() }; this.assets.push(asset); this.save(); this.renderAssetsPanel(); return asset; } async removeAsset(id) { const asset = this.assets.find(a => a.id === id); // If the asset is stored on the server, delete from server too if (asset && this.serverAvailable && asset.url && asset.url.startsWith('/storage/assets/')) { try { const filename = asset.id || asset.filename || asset.url.split('/').pop(); const resp = await fetch(API_BASE + '/api/assets/' + encodeURIComponent(filename), { method: 'DELETE' }); if (!resp.ok) { console.warn('Server-side asset deletion failed'); } } catch (e) { console.warn('Failed to delete asset from server:', e.message); } } this.assets = this.assets.filter(a => a.id !== id); this.save(); this.renderAssetsPanel(); } getByType(type) { return this.assets.filter(a => a.type === type); } // -- Assets Panel (left sidebar) -- initUI() { this.renderAssetsPanel(); } renderAssetsPanel() { const grid = document.getElementById('assets-grid'); if (!grid) return; grid.innerHTML = ''; if (this.assets.length === 0) { grid.innerHTML = '
No assets yet. Upload files or paste URLs above.
'; return; } // Group by type const groups = { image: [], video: [], css: [], js: [], file: [] }; this.assets.forEach(a => { (groups[a.type] || groups.file).push(a); }); // Render images/videos as grid thumbnails [...groups.image, ...groups.video].forEach(asset => { const item = this._createGridItem(asset); grid.appendChild(item); }); // Render CSS/JS as list items [...groups.css, ...groups.js, ...groups.file].forEach(asset => { const item = this._createListItem(asset); grid.appendChild(item); }); } _createGridItem(asset) { const item = document.createElement('div'); item.className = 'asset-grid-item'; item.dataset.assetId = asset.id; if (asset.type === 'image') { const img = document.createElement('img'); img.src = asset.url; img.alt = asset.name; img.style.cssText = 'width:100%;height:100%;object-fit:cover;'; item.appendChild(img); } else { const icon = document.createElement('div'); icon.className = 'asset-icon'; icon.innerHTML = '
🎬
'; item.appendChild(icon); } // Name label const label = document.createElement('div'); label.className = 'asset-label'; label.textContent = asset.name; item.appendChild(label); // Delete btn const del = document.createElement('button'); del.className = 'asset-delete-btn'; del.textContent = '\u00d7'; del.addEventListener('click', (e) => { e.stopPropagation(); this.removeAsset(asset.id); }); item.appendChild(del); // Click to copy URL item.addEventListener('click', () => { navigator.clipboard.writeText(asset.url).then(() => { item.style.outline = '2px solid #3b82f6'; setTimeout(() => item.style.outline = '', 800); }); }); return item; } _createListItem(asset) { const item = document.createElement('div'); item.className = 'asset-list-item'; item.dataset.assetId = asset.id; item.style.gridColumn = '1 / -1'; const icon = asset.type === 'css' ? '🎨' : asset.type === 'js' ? '⚡' : '📄'; item.innerHTML = ` ${icon} ${asset.name} `; const del = document.createElement('button'); del.className = 'asset-delete-btn'; del.textContent = '\u00d7'; del.style.cssText = 'position:static;width:20px;height:20px;flex-shrink:0;'; del.addEventListener('click', (e) => { e.stopPropagation(); this.removeAsset(asset.id); }); item.appendChild(del); item.addEventListener('click', () => { navigator.clipboard.writeText(asset.url).then(() => { item.style.outline = '2px solid #3b82f6'; setTimeout(() => item.style.outline = '', 800); }); }); return item; } // -- Drag & Drop -- initDragDrop() { const container = document.getElementById('assets-container'); if (!container) return; // Add drop zone const dropZone = document.createElement('div'); dropZone.id = 'asset-drop-zone'; dropZone.className = 'asset-drop-zone'; dropZone.innerHTML = '
📁
Drop files here
'; dropZone.style.display = 'none'; container.insertBefore(dropZone, container.firstChild); container.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.display = 'flex'; }); container.addEventListener('dragleave', (e) => { if (!container.contains(e.relatedTarget)) { dropZone.style.display = 'none'; } }); container.addEventListener('drop', (e) => { e.preventDefault(); dropZone.style.display = 'none'; this._handleFiles(e.dataTransfer.files); }); } _handleFiles(files) { Array.from(files).forEach(file => { let type = 'file'; if (file.type.startsWith('image/')) type = 'image'; else if (file.type.startsWith('video/')) type = 'video'; else if (file.name.endsWith('.css')) type = 'css'; else if (file.name.endsWith('.js')) type = 'js'; if (this.serverAvailable) { // Upload file directly to server (no base64 conversion) this._uploadFileToServer(file, type); } else { // No server: show error for file uploads this._showServerRequiredError(); } }); } // -- Asset Browser Modal -- initAssetBrowser() { // Create modal element const modal = document.createElement('div'); modal.id = 'asset-browser-modal'; modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); this.modal = modal; // Close handler modal.querySelector('#asset-browser-close').addEventListener('click', () => this.closeBrowser()); modal.addEventListener('click', (e) => { if (e.target === modal) this.closeBrowser(); }); // Tab switching modal.querySelectorAll('.asset-tab').forEach(tab => { tab.addEventListener('click', () => { modal.querySelectorAll('.asset-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); this.renderBrowserGrid(tab.dataset.type); }); }); // Upload inside browser const fileInput = modal.querySelector('#asset-browser-file'); modal.querySelector('#asset-browser-upload-btn').addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', async (e) => { const files = Array.from(e.target.files); fileInput.value = ''; // Upload files and re-render after all complete const uploads = files.map(file => { let type = 'file'; if (file.type.startsWith('image/')) type = 'image'; else if (file.type.startsWith('video/')) type = 'video'; else if (file.name.endsWith('.css')) type = 'css'; else if (file.name.endsWith('.js')) type = 'js'; if (this.serverAvailable) { return this._uploadFileToServer(file, type); } else { this._showServerRequiredError(); return Promise.resolve(null); } }); await Promise.all(uploads); const activeTab = modal.querySelector('.asset-tab.active'); this.renderBrowserGrid(activeTab?.dataset.type || 'image'); }); } async openBrowser(filterType) { if (!this.modal) return Promise.reject('No modal'); // Refresh assets from server to pick up uploads from other code paths if (this.serverAvailable) { try { const resp = await fetch(API_BASE + '/api/assets'); if (resp.ok) { const data = await resp.json(); if (data.success && Array.isArray(data.assets)) { this.assets = data.assets; this._saveMetadataIndex(); } } } catch (e) { // Use existing cached assets on failure } } // Set active tab const tabs = this.modal.querySelectorAll('.asset-tab'); tabs.forEach(t => t.classList.remove('active')); const targetTab = this.modal.querySelector(`.asset-tab[data-type="${filterType || 'image'}"]`) || tabs[0]; targetTab.classList.add('active'); this.renderBrowserGrid(filterType || 'image'); this.modal.classList.add('visible'); return new Promise((resolve) => { this.resolveSelection = resolve; }); } closeBrowser(selectedAsset) { if (this.modal) this.modal.classList.remove('visible'); if (this.resolveSelection) { this.resolveSelection(selectedAsset || null); this.resolveSelection = null; } } renderBrowserGrid(type) { const grid = this.modal.querySelector('#asset-browser-grid'); let items; if (type === 'all') { items = this.assets; } else if (type === 'video') { items = this.assets.filter(a => a.type === 'video' || (a.url && a.url.match(/\.(mp4|webm|ogg|mov|avi)$/i))); } else if (type === 'image') { items = this.assets.filter(a => a.type === 'image' || (a.url && a.url.match(/\.(jpg|jpeg|png|gif|webp|svg|ico)$/i))); } else if (type === 'file') { items = this.assets.filter(a => a.type === 'file' || (a.url && a.url.match(/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|csv)$/i))); } else { items = this.assets.filter(a => a.type === type); } if (items.length === 0) { grid.innerHTML = `
📂
No ${type === 'all' ? '' : type + ' '}assets yet.
Upload files above to get started.
`; return; } grid.innerHTML = ''; items.forEach(asset => { const item = document.createElement('div'); item.className = 'asset-browser-item'; if (asset.type === 'image') { item.innerHTML = `${asset.name}`; } else if (asset.type === 'video') { item.innerHTML = `
🎬
`; } else { const icon = asset.type === 'css' ? '🎨' : asset.type === 'js' ? '⚡' : '📄'; item.innerHTML = `
${icon}
`; } const nameDiv = document.createElement('div'); nameDiv.className = 'asset-browser-item-name'; nameDiv.textContent = asset.name; item.appendChild(nameDiv); item.addEventListener('click', () => { this.closeBrowser(asset); }); grid.appendChild(item); }); } // -- Patch Image Block Traits -- patchImageTraits() { const editor = this.editor; const self = this; // Override default image component to add "Browse Assets" button const origImageType = editor.DomComponents.getType('image'); const origModel = origImageType ? origImageType.model : null; editor.DomComponents.addType('image', { model: { defaults: { traits: [ { type: 'text', label: 'Image URL', name: 'src', placeholder: 'https://...' }, { type: 'text', label: 'Alt Text', name: 'alt' }, { type: 'text', label: 'Title', name: 'title' }, { type: 'button', label: '', text: '📁 Browse Assets', full: true, command: () => { self.openBrowser('image').then(asset => { if (!asset) return; const selected = editor.getSelected(); if (selected) { selected.addAttributes({ src: asset.url }); const el = selected.getEl(); if (el) el.src = asset.url; } }); } } ] } } }); } // -- Video traits are defined directly in editor.js component types -- // Browse Video Assets buttons reference window.assetManager.openBrowser() patchVideoTraits() { // No-op: browse buttons are now built into video-wrapper and video-section types } // -- Patch File/PDF Block Traits -- patchFileEmbedTraits() { const editor = this.editor; const self = this; const browseFileTrait = { type: 'button', label: '', text: '📁 Browse File Assets', full: true, command: () => { self.openBrowser('file').then(asset => { if (!asset) return; const selected = editor.getSelected(); if (!selected) return; selected.addAttributes({ fileUrl: asset.url }); const url = asset.url; const height = selected.getAttributes().frameHeight || 600; const isPdf = url.match(/\.pdf(\?.*)?$/i); const iframe = selected.components().find(c => c.getClasses().includes('file-embed-frame')); const placeholder = selected.components().find(c => c.getClasses().includes('file-embed-placeholder')); const downloadCard = selected.components().find(c => c.getClasses().includes('file-download-card')); if (isPdf) { // PDF: embed in iframe if (iframe) { iframe.addAttributes({ src: url }); iframe.addStyle({ display: 'block', height: height + 'px' }); const el = iframe.getEl(); if (el) { el.src = url; el.style.display = 'block'; el.style.height = height + 'px'; } } if (downloadCard) { downloadCard.addStyle({ display: 'none' }); const el = downloadCard.getEl(); if (el) el.style.display = 'none'; } } else { // Non-PDF: show download card if (iframe) { iframe.addStyle({ display: 'none' }); const el = iframe.getEl(); if (el) el.style.display = 'none'; } if (downloadCard) { const fileName = decodeURIComponent(url.split('/').pop().split('?')[0]) || 'File'; downloadCard.addAttributes({ href: url }); downloadCard.addStyle({ display: 'flex' }); const cardEl = downloadCard.getEl(); if (cardEl) { cardEl.href = url; cardEl.style.display = 'flex'; const nameNode = cardEl.querySelector('.file-download-name'); if (nameNode) nameNode.textContent = fileName; } } } if (placeholder) { placeholder.addStyle({ display: 'none' }); const el = placeholder.getEl(); if (el) el.style.display = 'none'; } }); } }; const fileEmbedType = editor.DomComponents.getType('file-embed'); if (fileEmbedType) { const origDefaults = fileEmbedType.model.prototype.defaults; const origTraits = Array.isArray(origDefaults.traits) ? origDefaults.traits : []; editor.DomComponents.addType('file-embed', { model: { defaults: { traits: [ ...origTraits.filter(t => t.type !== 'button'), browseFileTrait, ...origTraits.filter(t => t.type === 'button') ] } } }); } } // -- Deploy Button -- initDeployButton() { const navRight = document.querySelector('.nav-right'); if (!navRight) return; // Add deploy button before preview button const previewBtn = document.getElementById('btn-preview'); const deployBtn = document.createElement('button'); deployBtn.id = 'btn-deploy'; deployBtn.className = 'nav-btn'; deployBtn.title = 'Deploy to Server'; deployBtn.innerHTML = ` Deploy `; navRight.insertBefore(deployBtn, previewBtn); deployBtn.addEventListener('click', () => this.showDeployModal()); this.initDeployModal(); } initDeployModal() { const modal = document.createElement('div'); modal.id = 'deploy-modal'; modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); this.deployModal = modal; modal.querySelector('#deploy-close').addEventListener('click', () => this.closeDeployModal()); modal.querySelector('#deploy-cancel').addEventListener('click', () => this.closeDeployModal()); modal.addEventListener('click', (e) => { if (e.target === modal) this.closeDeployModal(); }); modal.querySelector('#deploy-export-zip').addEventListener('click', () => this.exportDeployZip()); modal.querySelector('#deploy-start').addEventListener('click', () => this.startDeploy()); } showDeployModal() { // Save current page if (window.editor) { const pages = window.sitePages; const currentPageId = pages?.find(p => true)?.id; // simple fallback } const pageCount = window.sitePages?.length || 0; const assetCount = this.assets.length; this.deployModal.querySelector('#deploy-page-count').textContent = pageCount; this.deployModal.querySelector('#deploy-asset-count').textContent = assetCount; this.deployModal.querySelector('#deploy-status').style.display = 'none'; this.deployModal.classList.add('visible'); } closeDeployModal() { this.deployModal.classList.remove('visible'); } // Generate deployment files structure generateDeployFiles() { const pages = window.sitePages || []; const extractCss = this.deployModal.querySelector('#deploy-extract-css').checked; const includeFonts = this.deployModal.querySelector('#deploy-include-fonts').checked; const headCode = localStorage.getItem('sitebuilder-head-code') || ''; const sitewideCss = localStorage.getItem('sitebuilder-sitewide-css') || ''; const files = {}; // Collect all page CSS into one file if extracting let combinedCss = ''; const fontsLink = includeFonts ? '\n \n ' : ''; const baseCss = `/* Reset & Base */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.5; -webkit-font-smoothing: antialiased; } img, video { max-width: 100%; height: auto; } img { display: block; } a { color: inherit; } :focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } :focus:not(:focus-visible) { outline: none; } a, button, input, select, textarea { min-height: 44px; } button, [type="submit"] { cursor: pointer; touch-action: manipulation; } @media (max-width: 768px) { .row { flex-direction: column !important; } .row .cell { flex-basis: 100% !important; width: 100% !important; } section { padding-left: 16px !important; padding-right: 16px !important; } } @media (max-width: 480px) { .row { flex-direction: column !important; } .row .cell { flex-basis: 100% !important; width: 100% !important; } h1 { font-size: 32px !important; } h2 { font-size: 28px !important; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }`; if (extractCss) { combinedCss = baseCss + '\n\n/* Site-wide Styles */\n' + sitewideCss + '\n'; } pages.forEach(page => { const pageCss = page.css || ''; if (extractCss) { combinedCss += `\n/* Page: ${page.name} */\n${pageCss}\n`; // HTML without inline CSS const html = ` ${page.name} ${fontsLink} ${headCode} Skip to main content
${page.html || ''}
`; files[page.slug + '.html'] = html; } else { // Inline CSS (use existing generatePageHtml pattern) const html = ` ${page.name} ${fontsLink} ${headCode} Skip to main content
${page.html || ''}
`; files[page.slug + '.html'] = html; } }); if (extractCss) { files['assets/css/styles.css'] = combinedCss; } // For server-stored assets, they are actual files on disk referenced by URL. // For deploy, we note them so the ZIP export can fetch and include them. this.assets.forEach(asset => { if (asset.url && asset.url.startsWith('/storage/assets/')) { // Server-stored asset: include as a fetchable file reference const subfolder = asset.type === 'image' ? 'images' : asset.type === 'video' ? 'videos' : asset.type === 'css' ? 'css' : asset.type === 'js' ? 'js' : 'files'; files[`assets/${subfolder}/${asset.name}`] = { fetchUrl: asset.url, type: 'fetch' }; } else if (asset.url && !asset.url.startsWith('data:')) { // External URL assets - referenced directly, no file needed } else if (asset.url && asset.url.startsWith('data:')) { // Legacy data URL assets (should not exist with new system) const subfolder = asset.type === 'image' ? 'images' : asset.type === 'video' ? 'videos' : asset.type === 'css' ? 'css' : asset.type === 'js' ? 'js' : 'files'; files[`assets/${subfolder}/${asset.name}`] = asset.url; } }); return files; } async exportDeployZip() { // Load JSZip if needed if (typeof JSZip === 'undefined') { await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js'; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); } const files = this.generateDeployFiles(); const zip = new JSZip(); for (const [path, content] of Object.entries(files)) { if (content && typeof content === 'object' && content.type === 'fetch') { // Fetch file from server and add to ZIP try { const resp = await fetch(content.fetchUrl); if (resp.ok) { const blob = await resp.blob(); zip.file(path, blob); } } catch (e) { console.warn('Could not fetch asset for ZIP:', content.fetchUrl, e.message); } } else if (typeof content === 'string' && content.startsWith('data:')) { // Convert data URL to binary (legacy) const parts = content.split(','); const byteString = atob(parts[1]); const bytes = new Uint8Array(byteString.length); for (let i = 0; i < byteString.length; i++) { bytes[i] = byteString.charCodeAt(i); } zip.file(path, bytes); } else if (typeof content === 'string') { zip.file(path, content); } } const blob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'site-deploy.zip'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } async startDeploy() { const statusDiv = this.deployModal.querySelector('#deploy-status'); const progressBar = this.deployModal.querySelector('#deploy-progress'); const statusText = this.deployModal.querySelector('#deploy-status-text'); statusDiv.style.display = 'block'; const updateProgress = (pct, msg) => { progressBar.style.width = pct + '%'; statusText.textContent = msg; }; updateProgress(10, 'Generating files...'); const files = this.generateDeployFiles(); const fileList = Object.keys(files); updateProgress(20, `Prepared ${fileList.length} files. Creating deploy package...`); // Export as ZIP for manual SFTP upload try { await this.exportDeployZip(); updateProgress(100, 'Deploy package downloaded! Upload via SFTP to complete deployment.'); statusText.innerHTML = `ZIP downloaded!

# Upload via terminal:
cd ~/Downloads
unzip site-deploy.zip -d site-deploy
sftp shadowdao@whp01.cloud-hosting.io
cd wildcard.streamers.channel
put -r site-deploy/*
`; } catch (err) { updateProgress(100, 'Error: ' + err.message); } } } // Initialize when editor is ready function waitForEditor() { if (window.editor) { window.assetManager = new AssetManager(window.editor); } else { setTimeout(waitForEditor, 200); } } waitForEditor(); })();