Files
site-builder/js/assets.js
Josh Knapp 03f573b451 Replace Google Docs viewer with download card for non-PDF file embeds
PDF files continue to embed in an iframe. Non-PDF files (DOC, DOCX, XLS,
etc.) now show a download card with the file name and download icon instead
of relying on Google Docs Viewer, which often fails with "No preview
available."

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:13:02 -08:00

1068 lines
47 KiB
JavaScript

/**
* 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 = '<div style="grid-column:1/-1;text-align:center;color:#71717a;padding:24px;font-size:13px;">No assets yet. Upload files or paste URLs above.</div>';
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 = '<div style="font-size:28px;">🎬</div>';
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 = `
<span class="asset-list-icon">${icon}</span>
<span class="asset-list-name">${asset.name}</span>
`;
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 = '<div class="drop-zone-text"><div style="font-size:32px;margin-bottom:8px;">📁</div>Drop files here</div>';
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 = `
<div class="modal" style="max-width:720px;width:90%;">
<div class="modal-header">
<h3 class="modal-title">Browse Assets</h3>
<button class="modal-close" id="asset-browser-close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body" style="padding:0;">
<div class="asset-browser-tabs">
<button class="asset-tab active" data-type="image">Images</button>
<button class="asset-tab" data-type="video">Videos</button>
<button class="asset-tab" data-type="file">Files</button>
<button class="asset-tab" data-type="all">All</button>
</div>
<div class="asset-browser-upload" style="padding:12px 16px;border-bottom:1px solid #2d2d3a;">
<input type="file" id="asset-browser-file" multiple accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.css,.js" style="display:none;">
<button id="asset-browser-upload-btn" class="add-page-btn" style="width:100%;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
Upload New File
</button>
</div>
<div id="asset-browser-grid" class="asset-browser-grid"></div>
</div>
</div>
`;
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 = `<div style="grid-column:1/-1;text-align:center;padding:48px 24px;color:#71717a;">
<div style="font-size:36px;margin-bottom:12px;">📂</div>
<div>No ${type === 'all' ? '' : type + ' '}assets yet.</div>
<div style="font-size:12px;margin-top:4px;">Upload files above to get started.</div>
</div>`;
return;
}
grid.innerHTML = '';
items.forEach(asset => {
const item = document.createElement('div');
item.className = 'asset-browser-item';
if (asset.type === 'image') {
item.innerHTML = `<img src="${asset.url}" alt="${asset.name}" style="width:100%;height:100%;object-fit:cover;">`;
} else if (asset.type === 'video') {
item.innerHTML = `<div class="asset-icon"><div style="font-size:36px;">🎬</div></div>`;
} else {
const icon = asset.type === 'css' ? '🎨' : asset.type === 'js' ? '⚡' : '📄';
item.innerHTML = `<div class="asset-icon"><div style="font-size:36px;">${icon}</div></div>`;
}
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 = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 2L11 13"></path>
<path d="M22 2L15 22L11 13L2 9L22 2Z"></path>
</svg>
<span>Deploy</span>
`;
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 = `
<div class="modal" style="max-width:560px;">
<div class="modal-header">
<h3 class="modal-title">Deploy Site</h3>
<button class="modal-close" id="deploy-close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<p class="export-description">Deploy your site to <strong>abc.streamers.channel</strong> via SFTP.</p>
<div class="deploy-info">
<div class="deploy-info-row"><span>Host:</span><span>whp01.cloud-hosting.io</span></div>
<div class="deploy-info-row"><span>User:</span><span>shadowdao</span></div>
<div class="deploy-info-row"><span>Directory:</span><span>wildcard.streamers.channel/</span></div>
<div class="deploy-info-row"><span>Pages:</span><span id="deploy-page-count">0</span></div>
<div class="deploy-info-row"><span>Assets:</span><span id="deploy-asset-count">0</span></div>
</div>
<div class="export-options">
<label class="checkbox-label">
<input type="checkbox" id="deploy-extract-css" checked> Extract CSS to external files
</label>
<label class="checkbox-label">
<input type="checkbox" id="deploy-include-fonts" checked> Include Google Fonts
</label>
</div>
<div id="deploy-status" class="deploy-status" style="display:none;">
<div class="deploy-progress-bar"><div class="deploy-progress-fill" id="deploy-progress"></div></div>
<div id="deploy-status-text" class="deploy-status-text">Preparing files...</div>
</div>
</div>
<div class="modal-footer">
<button class="modal-btn modal-btn-secondary" id="deploy-cancel">Cancel</button>
<button class="modal-btn modal-btn-secondary" id="deploy-export-zip">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Export ZIP
</button>
<button class="modal-btn modal-btn-primary" id="deploy-start">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 2L11 13"></path>
<path d="M22 2L15 22L11 13L2 9L22 2Z"></path>
</svg>
Deploy Now
</button>
</div>
</div>
`;
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
? '<link rel="preconnect" href="https://fonts.googleapis.com">\n <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;500;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet">'
: '';
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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>${page.name}</title>
${fontsLink}
<link rel="stylesheet" href="assets/css/styles.css">
${headCode}
</head>
<body>
<a href="#main-content" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;">Skip to main content</a>
<main id="main-content">
${page.html || ''}
</main>
</body>
</html>`;
files[page.slug + '.html'] = html;
} else {
// Inline CSS (use existing generatePageHtml pattern)
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>${page.name}</title>
${fontsLink}
${headCode}
<style>
${baseCss}
/* Site-wide Styles */
${sitewideCss}
/* Page Styles */
${pageCss}
</style>
</head>
<body>
<a href="#main-content" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;">Skip to main content</a>
<main id="main-content">
${page.html || ''}
</main>
</body>
</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!<br><br>
<code style="font-size:11px;color:#a1a1aa;display:block;margin-top:8px;">
# Upload via terminal:<br>
cd ~/Downloads<br>
unzip site-deploy.zip -d site-deploy<br>
sftp shadowdao@whp01.cloud-hosting.io<br>
cd wildcard.streamers.channel<br>
put -r site-deploy/*<br>
</code>`;
} 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();
})();