2026-02-28 19:25:42 +00:00
|
|
|
/**
|
|
|
|
|
* 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) => {
|
2026-03-01 14:13:02 -08:00
|
|
|
const files = Array.from(e.target.files);
|
2026-02-28 19:25:42 +00:00
|
|
|
fileInput.value = '';
|
2026-03-01 14:13:02 -08:00
|
|
|
// 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');
|
2026-02-28 19:25:42 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 14:13:02 -08:00
|
|
|
async openBrowser(filterType) {
|
2026-02-28 19:25:42 +00:00
|
|
|
if (!this.modal) return Promise.reject('No modal');
|
|
|
|
|
|
2026-03-01 14:13:02 -08:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 19:25:42 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 14:13:02 -08:00
|
|
|
// -- Video traits are defined directly in editor.js component types --
|
|
|
|
|
// Browse Video Assets buttons reference window.assetManager.openBrowser()
|
2026-02-28 19:25:42 +00:00
|
|
|
patchVideoTraits() {
|
2026-03-01 14:13:02 -08:00
|
|
|
// No-op: browse buttons are now built into video-wrapper and video-section types
|
2026-02-28 19:25:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- 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;
|
2026-03-01 14:13:02 -08:00
|
|
|
const isPdf = url.match(/\.pdf(\?.*)?$/i);
|
2026-02-28 19:25:42 +00:00
|
|
|
|
|
|
|
|
const iframe = selected.components().find(c => c.getClasses().includes('file-embed-frame'));
|
|
|
|
|
const placeholder = selected.components().find(c => c.getClasses().includes('file-embed-placeholder'));
|
2026-03-01 14:13:02 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-28 19:25:42 +00:00
|
|
|
}
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
})();
|