feat: adicionar storage manager API e painel de gestão de clones
This commit is contained in:
@@ -0,0 +1,519 @@
|
|||||||
|
// ============================================
|
||||||
|
// BRAINSTEEL WEB - STORAGE PANEL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
let storageState = {
|
||||||
|
clones: [],
|
||||||
|
selectedClone: null,
|
||||||
|
viewMode: 'grid' // 'grid' or 'list'
|
||||||
|
};
|
||||||
|
|
||||||
|
let storageApi = {
|
||||||
|
// Fetch all clones from API
|
||||||
|
async fetchClones() {
|
||||||
|
const res = await fetch('/api/clones');
|
||||||
|
const data = await res.json();
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get single clone details
|
||||||
|
async getClone(id) {
|
||||||
|
const res = await fetch(`/api/clones/${id}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update clone metadata
|
||||||
|
async updateClone(id, updates) {
|
||||||
|
const res = await fetch(`/api/clones/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete clone
|
||||||
|
async deleteClone(id) {
|
||||||
|
const res = await fetch(`/api/clones/${id}`, { method: 'DELETE' });
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create version snapshot
|
||||||
|
async createVersion(id) {
|
||||||
|
const res = await fetch(`/api/clones/${id}/versions`, { method: 'POST' });
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Restore from version
|
||||||
|
async restoreVersion(id, versionId) {
|
||||||
|
const res = await fetch(`/api/clones/${id}/restore/${versionId}`, { method: 'POST' });
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete version
|
||||||
|
async deleteVersion(id, versionId) {
|
||||||
|
const res = await fetch(`/api/clones/${id}/versions/${versionId}`, { method: 'DELETE' });
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// UI FUNCTIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async function loadStoragePanel() {
|
||||||
|
const container = document.getElementById('storageContent');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
showStorageLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await storageApi.fetchClones();
|
||||||
|
storageState.clones = data.clones;
|
||||||
|
|
||||||
|
renderStorageSummary(data.summary);
|
||||||
|
renderClonesGrid(data.clones);
|
||||||
|
showStorageLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao carregar storage:', err);
|
||||||
|
container.innerHTML = `<div class="storage-error">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<p>Erro ao carregar clones: ${err.message}</p>
|
||||||
|
<button class="btn btn-secondary" onclick="loadStoragePanel()">
|
||||||
|
<i class="fas fa-redo"></i> Tentar novamente
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
showStorageLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStorageLoading(show) {
|
||||||
|
const loader = document.getElementById('storageLoader');
|
||||||
|
if (loader) loader.style.display = show ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStorageSummary(summary) {
|
||||||
|
const el = document.getElementById('storageSummary');
|
||||||
|
if (!el || !summary) return;
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="summary-card">
|
||||||
|
<i class="fas fa-globe"></i>
|
||||||
|
<div>
|
||||||
|
<h4>${summary.total}</h4>
|
||||||
|
<p>Total de Clones</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<i class="fas fa-hdd"></i>
|
||||||
|
<div>
|
||||||
|
<h4>${summary.formattedSize || '0 MB'}</h4>
|
||||||
|
<p>Espaço Usado</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<div>
|
||||||
|
<h4>${summary.byStatus?.active || 0}</h4>
|
||||||
|
<p>Ativos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<i class="fas fa-archive"></i>
|
||||||
|
<div>
|
||||||
|
<h4>${summary.byStatus?.archived || 0}</h4>
|
||||||
|
<p>Arquivados</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClonesGrid(clones) {
|
||||||
|
const container = document.getElementById('storageClonesGrid');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!clones || clones.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="storage-empty">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
<h3>Nenhum clone encontrado</h3>
|
||||||
|
<p>Clone um site para começar a gerenciar seu storage</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = clones.map(clone => `
|
||||||
|
<div class="clone-card" data-id="${clone.id}">
|
||||||
|
<div class="clone-card-header">
|
||||||
|
<div class="clone-status ${clone.metadata?.status || 'active'}"></div>
|
||||||
|
<div class="clone-card-actions">
|
||||||
|
<button class="btn-icon" onclick="event.stopPropagation(); showCloneMenu('${clone.id}')" title="Menu">
|
||||||
|
<i class="fas fa-ellipsis-v"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clone-card-body" onclick="showCloneDetails('${clone.id}')">
|
||||||
|
<div class="clone-icon">
|
||||||
|
<i class="fas fa-globe"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="clone-name" title="${clone.baseUrl}">${truncateUrl(clone.baseUrl)}</h4>
|
||||||
|
<p class="clone-url">${clone.baseUrl}</p>
|
||||||
|
<div class="clone-meta">
|
||||||
|
<span><i class="fas fa-clock"></i> ${formatDate(clone.cloneInfo?.clonedAt)}</span>
|
||||||
|
<span><i class="fas fa-file"></i> ${clone.stats?.fileCount || 0} arquivos</span>
|
||||||
|
<span><i class="fas fa-hdd"></i> ${clone.stats?.formattedSize || '0 B'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clone-card-footer">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); openClone('${clone.id}')">
|
||||||
|
<i class="fas fa-external-link-alt"></i> Abrir
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="event.stopPropagation(); showCloneVersions('${clone.id}')">
|
||||||
|
<i class="fas fa-history"></i> Versões
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); cloneSiteAgain('${clone.baseUrl}')">
|
||||||
|
<i class="fas fa-redo"></i> Reclonar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCloneDetails(cloneId) {
|
||||||
|
const clone = storageState.clones.find(c => c.id === cloneId);
|
||||||
|
if (!clone) return;
|
||||||
|
|
||||||
|
storageState.selectedClone = clone;
|
||||||
|
|
||||||
|
const modal = createModal('cloneDetailsModal', `
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3><i class="fas fa-globe"></i> ${clone.baseUrl}</h3>
|
||||||
|
<button class="modal-close" onclick="closeModal('cloneDetailsModal')">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="clone-details-grid">
|
||||||
|
<div class="detail-item">
|
||||||
|
<label>URL Original</label>
|
||||||
|
<a href="${clone.cloneInfo?.originalUrl || '#'}" target="_blank">${clone.cloneInfo?.originalUrl || 'N/A'}</a>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<label>Data da Clonagem</label>
|
||||||
|
<span>${formatDate(clone.cloneInfo?.clonedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<label>Tamanho</label>
|
||||||
|
<span>${clone.stats?.formattedSize || '0 B'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<label>Arquivos</label>
|
||||||
|
<span>${clone.stats?.fileCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<label>Status</label>
|
||||||
|
<select onchange="updateCloneStatus('${clone.id}', this.value)">
|
||||||
|
<option value="active" ${(clone.metadata?.status) === 'active' ? 'selected' : ''}>Ativo</option>
|
||||||
|
<option value="archived" ${(clone.metadata?.status) === 'archived' ? 'selected' : ''}>Arquivado</option>
|
||||||
|
<option value="redesign" ${(clone.metadata?.status) === 'redesign' ? 'selected' : ''}>Em Redesign</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<label>Tags</label>
|
||||||
|
<input type="text" id="cloneTagsInput" value="${clone.metadata?.tags?.join(', ') || ''}"
|
||||||
|
placeholder="Separar por vírgulas" onblur="saveCloneTags('${clone.id}', this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clone-versions-section">
|
||||||
|
<h4><i class="fas fa-history"></i> Versões Salvas</h4>
|
||||||
|
<div id="cloneVersionsList" class="versions-list">
|
||||||
|
${renderVersionsList(clone.versions)}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" onclick="createNewVersion('${clone.id}')">
|
||||||
|
<i class="fas fa-camera"></i> Criar Snapshot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clone-html-files">
|
||||||
|
<h4><i class="fas fa-file-code"></i> Arquivos HTML</h4>
|
||||||
|
<ul>
|
||||||
|
${(clone.htmlFiles || []).map(f => `<li><a href="/${clone.id}/${f}" target="_blank">${f}</a></li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-danger" onclick="confirmDeleteClone('${clone.id}')">
|
||||||
|
<i class="fas fa-trash"></i> Excluir Clone
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('cloneDetailsModal')">Fechar</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
showModal('cloneDetailsModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVersionsList(versions) {
|
||||||
|
if (!versions || versions.length === 0) {
|
||||||
|
return '<p class="text-muted">Nenhuma versão salva. Clique em "Criar Snapshot" para salvar o estado atual.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions.map(v => `
|
||||||
|
<div class="version-item">
|
||||||
|
<div class="version-info">
|
||||||
|
<i class="fas fa-archive"></i>
|
||||||
|
<span>${v.id}</span>
|
||||||
|
<span class="version-date">${formatDate(v.createdAt)}</span>
|
||||||
|
<span class="version-size">${formatBytes(v.size)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="version-actions">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="restoreCloneVersion('${storageState.selectedClone?.id}', '${v.id}')">
|
||||||
|
<i class="fas fa-undo"></i> Restaurar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteCloneVersion('${storageState.selectedClone?.id}', '${v.id}')">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCloneVersions(cloneId) {
|
||||||
|
const clone = storageState.clones.find(c => c.id === cloneId);
|
||||||
|
if (clone) {
|
||||||
|
storageState.selectedClone = clone;
|
||||||
|
showCloneDetails(cloneId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ACTIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async function updateCloneStatus(cloneId, status) {
|
||||||
|
try {
|
||||||
|
await storageApi.updateClone(cloneId, { status });
|
||||||
|
showToast('Status atualizado', 'success');
|
||||||
|
loadStoragePanel();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Erro ao atualizar status', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCloneTags(cloneId, tagsString) {
|
||||||
|
const tags = tagsString.split(',').map(t => t.trim()).filter(t => t);
|
||||||
|
try {
|
||||||
|
await storageApi.updateClone(cloneId, { tags });
|
||||||
|
showToast('Tags salvas', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Erro ao salvar tags', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewVersion(cloneId) {
|
||||||
|
try {
|
||||||
|
showToast('Criando snapshot...', 'info');
|
||||||
|
const result = await storageApi.createVersion(cloneId);
|
||||||
|
if (result.success) {
|
||||||
|
showToast('Snapshot criado com sucesso', 'success');
|
||||||
|
// Refresh the details modal
|
||||||
|
showCloneDetails(cloneId);
|
||||||
|
loadStoragePanel();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Erro ao criar snapshot: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreCloneVersion(cloneId, versionId) {
|
||||||
|
if (!confirm('Restaurar esta versão? O estado atual será salvo como backup.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
showToast('Restaurando versão...', 'info');
|
||||||
|
const result = await storageApi.restoreVersion(cloneId, versionId);
|
||||||
|
if (result.success) {
|
||||||
|
showToast('Versão restaurada com sucesso', 'success');
|
||||||
|
closeModal('cloneDetailsModal');
|
||||||
|
loadStoragePanel();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Erro ao restaurar: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCloneVersion(cloneId, versionId) {
|
||||||
|
if (!confirm('Excluir esta versão?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await storageApi.deleteVersion(cloneId, versionId);
|
||||||
|
if (result.success) {
|
||||||
|
showToast('Versão removida', 'success');
|
||||||
|
showCloneDetails(cloneId);
|
||||||
|
loadStoragePanel();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Erro ao remover versão', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteClone(cloneId) {
|
||||||
|
if (!confirm('Tem certeza que deseja excluir este clone permanentemente?')) return;
|
||||||
|
|
||||||
|
storageApi.deleteClone(cloneId)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
showToast('Clone excluído', 'success');
|
||||||
|
closeModal('cloneDetailsModal');
|
||||||
|
loadStoragePanel();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => showToast('Erro ao excluir: ' + err.message, 'error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openClone(cloneId) {
|
||||||
|
window.open(`/${cloneId}/index.html`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneSiteAgain(baseUrl) {
|
||||||
|
// Navigate to new clone page with URL pre-filled
|
||||||
|
switchAgentTab('designer');
|
||||||
|
const urlInput = document.getElementById('designUrl');
|
||||||
|
if (urlInput) {
|
||||||
|
urlInput.value = baseUrl.startsWith('http') ? baseUrl : 'https://' + baseUrl;
|
||||||
|
}
|
||||||
|
showToast(`URL carregada: ${baseUrl}`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCloneMenu(cloneId) {
|
||||||
|
// Simple context menu
|
||||||
|
const clone = storageState.clones.find(c => c.id === cloneId);
|
||||||
|
if (!clone) return;
|
||||||
|
|
||||||
|
const action = prompt(`Ações para ${clone.baseUrl}:
|
||||||
|
1. Abrir
|
||||||
|
2. Detalhes
|
||||||
|
3. Versões
|
||||||
|
4. Reclonar
|
||||||
|
5. Excluir
|
||||||
|
|
||||||
|
Digite o número:`);
|
||||||
|
|
||||||
|
if (action === '1') openClone(cloneId);
|
||||||
|
else if (action === '2') showCloneDetails(cloneId);
|
||||||
|
else if (action === '3') showCloneVersions(cloneId);
|
||||||
|
else if (action === '4') cloneSiteAgain(clone.baseUrl);
|
||||||
|
else if (action === '5') confirmDeleteClone(cloneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MODAL HELPERS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function createModal(id, content) {
|
||||||
|
const existing = document.getElementById(id);
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = id;
|
||||||
|
modal.className = 'modal';
|
||||||
|
modal.innerHTML = `<div class="modal-content">${content}</div>`;
|
||||||
|
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('modal')) closeModal(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal(id) {
|
||||||
|
const modal = document.getElementById(id);
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
setTimeout(() => modal.classList.add('active'), 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
const modal = document.getElementById(id);
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
setTimeout(() => modal.remove(), 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// UTILITIES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function truncateUrl(url, maxLen = 30) {
|
||||||
|
if (!url) return '';
|
||||||
|
if (url.length <= maxLen) return url;
|
||||||
|
return url.substring(0, maxLen) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
// Simple alert for now - could be replaced with a proper toast system
|
||||||
|
console.log(`[${type}] ${message}`);
|
||||||
|
// Create a simple toast element
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: ${type === 'success' ? '#43e97b' : type === 'error' ? '#f5576c' : '#667eea'};
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// INIT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// If storage page element exists, load the panel
|
||||||
|
const storageContent = document.getElementById('storageContent');
|
||||||
|
if (storageContent) {
|
||||||
|
loadStoragePanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add storage nav click handler
|
||||||
|
const storageNav = document.querySelector('[data-page="storage"]');
|
||||||
|
if (storageNav) {
|
||||||
|
storageNav.addEventListener('click', loadStoragePanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Storage Panel initialized');
|
||||||
|
});
|
||||||
@@ -7,6 +7,10 @@ const { exec, spawn } = require('child_process');
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Storage manager routes
|
||||||
|
const storageManager = require('./storage-manager');
|
||||||
|
app.use('/api/clones', storageManager);
|
||||||
|
|
||||||
// Security headers
|
// Security headers
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
/**
|
||||||
|
* CloneWeb Storage Manager
|
||||||
|
* Gerencia versões, metadados e storage dos sites clonados
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const BASE_DIR = '/app/cloned-sites';
|
||||||
|
const METADATA_FILE = 'clone-metadata.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all clones with their metadata
|
||||||
|
*/
|
||||||
|
function getAllClones() {
|
||||||
|
const clones = [];
|
||||||
|
|
||||||
|
if (!fs.existsSync(BASE_DIR)) {
|
||||||
|
return clones;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs = fs.readdirSync(BASE_DIR).filter(f => {
|
||||||
|
return fs.statSync(path.join(BASE_DIR, f)).isDirectory();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const clonePath = path.join(BASE_DIR, dir);
|
||||||
|
const metadata = loadMetadata(clonePath);
|
||||||
|
const cloneInfo = loadCloneInfo(clonePath);
|
||||||
|
const stats = getCloneStats(clonePath);
|
||||||
|
|
||||||
|
// Extract base URL from directory name
|
||||||
|
const baseUrl = extractBaseUrl(dir);
|
||||||
|
|
||||||
|
clones.push({
|
||||||
|
id: dir,
|
||||||
|
directory: dir,
|
||||||
|
baseUrl,
|
||||||
|
path: clonePath,
|
||||||
|
metadata: metadata || {},
|
||||||
|
cloneInfo: cloneInfo || {},
|
||||||
|
stats,
|
||||||
|
versions: listVersions(clonePath)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by most recent
|
||||||
|
clones.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.cloneInfo.clonedAt || a.metadata.createdAt || 0);
|
||||||
|
const dateB = new Date(b.cloneInfo.clonedAt || b.metadata.createdAt || 0);
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
return clones;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMetadata(clonePath) {
|
||||||
|
const metaFile = path.join(clonePath, METADATA_FILE);
|
||||||
|
if (fs.existsSync(metaFile)) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(metaFile, 'utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCloneInfo(clonePath) {
|
||||||
|
const infoFile = path.join(clonePath, 'clone-info.json');
|
||||||
|
if (fs.existsSync(infoFile)) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(infoFile, 'utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCloneStats(clonePath) {
|
||||||
|
let totalSize = 0;
|
||||||
|
let fileCount = 0;
|
||||||
|
|
||||||
|
function countFiles(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
|
||||||
|
const items = fs.readdirSync(dir);
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = path.join(dir, item);
|
||||||
|
const stat = fs.statSync(itemPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
// Skip cloned-sites subdirectory
|
||||||
|
if (!item.includes('cloned-sites')) {
|
||||||
|
countFiles(itemPath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
totalSize += stat.size;
|
||||||
|
fileCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
countFiles(clonePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSize,
|
||||||
|
fileCount,
|
||||||
|
formattedSize: formatBytes(totalSize)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listVersions(clonePath) {
|
||||||
|
const versions = [];
|
||||||
|
const versionsDir = path.join(clonePath, '.versions');
|
||||||
|
|
||||||
|
if (fs.existsSync(versionsDir)) {
|
||||||
|
const versionDirs = fs.readdirSync(versionsDir);
|
||||||
|
for (const vDir of versionDirs) {
|
||||||
|
const vPath = path.join(versionsDir, vDir);
|
||||||
|
const stat = fs.statSync(vPath);
|
||||||
|
versions.push({
|
||||||
|
id: vDir,
|
||||||
|
path: vPath,
|
||||||
|
createdAt: stat.birthtime,
|
||||||
|
size: getDirSize(vPath)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirSize(dirPath) {
|
||||||
|
let size = 0;
|
||||||
|
function count(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
for (const item of fs.readdirSync(dir)) {
|
||||||
|
const stat = fs.statSync(path.join(dir, item));
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
count(path.join(dir, item));
|
||||||
|
} else {
|
||||||
|
size += stat.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count(dirPath);
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBaseUrl(dirName) {
|
||||||
|
// "example.com_2026-05-17T20-58-30-760Z" -> "example.com"
|
||||||
|
const parts = dirName.split('_');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
// Check if last part is a timestamp
|
||||||
|
const lastPart = parts[parts.length - 1];
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T/.test(lastPart)) {
|
||||||
|
return parts.slice(0, -1).join('_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API ROUTES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// GET /api/clones - List all clones
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const clones = getAllClones();
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: clones.length,
|
||||||
|
totalSize: clones.reduce((sum, c) => sum + c.stats.totalSize, 0),
|
||||||
|
formattedSize: formatBytes(clones.reduce((sum, c) => sum + c.stats.totalSize, 0)),
|
||||||
|
byStatus: {
|
||||||
|
active: clones.filter(c => c.metadata?.status === 'active').length,
|
||||||
|
archived: clones.filter(c => c.metadata?.status === 'archived').length,
|
||||||
|
redesign: clones.filter(c => c.metadata?.status === 'redesign').length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
clones,
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao listar clones:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/clones/:id - Get single clone details
|
||||||
|
router.get('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const clonePath = path.join(BASE_DIR, req.params.id);
|
||||||
|
|
||||||
|
if (!fs.existsSync(clonePath)) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Clone não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = loadMetadata(clonePath);
|
||||||
|
const cloneInfo = loadCloneInfo(clonePath);
|
||||||
|
const stats = getCloneStats(clonePath);
|
||||||
|
const versions = listVersions(clonePath);
|
||||||
|
|
||||||
|
// Get list of HTML files
|
||||||
|
const htmlFiles = findFiles(clonePath, '.html');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: req.params.id,
|
||||||
|
baseUrl: extractBaseUrl(req.params.id),
|
||||||
|
path: clonePath,
|
||||||
|
metadata: metadata || { createdAt: cloneInfo?.clonedAt },
|
||||||
|
cloneInfo,
|
||||||
|
stats,
|
||||||
|
versions,
|
||||||
|
htmlFiles
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar clone:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/clones/:id - Update clone metadata
|
||||||
|
router.patch('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const clonePath = path.join(BASE_DIR, req.params.id);
|
||||||
|
|
||||||
|
if (!fs.existsSync(clonePath)) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Clone não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaFile = path.join(clonePath, METADATA_FILE);
|
||||||
|
let metadata = loadMetadata(clonePath) || {};
|
||||||
|
|
||||||
|
// Merge updates
|
||||||
|
metadata = {
|
||||||
|
...metadata,
|
||||||
|
...req.body,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(metaFile, JSON.stringify(metadata, null, 2));
|
||||||
|
|
||||||
|
res.json({ success: true, data: { metadata } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar clone:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/clones/:id/versions - Create a version snapshot
|
||||||
|
router.post('/:id/versions', (req, res) => {
|
||||||
|
try {
|
||||||
|
const clonePath = path.join(BASE_DIR, req.params.id);
|
||||||
|
|
||||||
|
if (!fs.existsSync(clonePath)) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Clone não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionsDir = path.join(clonePath, '.versions');
|
||||||
|
const versionId = `v${Date.now()}`;
|
||||||
|
const versionPath = path.join(versionsDir, versionId);
|
||||||
|
|
||||||
|
fs.mkdirSync(versionPath, { recursive: true });
|
||||||
|
|
||||||
|
// Copy all files to version (except .versions itself)
|
||||||
|
copyDirectory(clonePath, versionPath, ['.versions']);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
versionId,
|
||||||
|
path: versionPath,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao criar versão:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/clones/:id/versions/:versionId - Delete a version
|
||||||
|
router.delete('/:id/versions/:versionId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const versionPath = path.join(BASE_DIR, req.params.id, '.versions', req.params.versionId);
|
||||||
|
|
||||||
|
if (!fs.existsSync(versionPath)) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Versão não encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(versionPath, { recursive: true, force: true });
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Versão removida' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao remover versão:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/clones/:id/restore/:versionId - Restore from version
|
||||||
|
router.post('/:id/restore/:versionId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const clonePath = path.join(BASE_DIR, req.params.id);
|
||||||
|
const versionPath = path.join(clonePath, '.versions', req.params.versionId);
|
||||||
|
|
||||||
|
if (!fs.existsSync(versionPath)) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Versão não encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup current state first
|
||||||
|
const backupId = `pre-restore-${Date.now()}`;
|
||||||
|
const backupPath = path.join(clonePath, '.versions', backupId);
|
||||||
|
fs.mkdirSync(backupPath, { recursive: true });
|
||||||
|
copyDirectory(clonePath, backupPath, ['.versions']);
|
||||||
|
|
||||||
|
// Create restore point
|
||||||
|
const restorePointId = `pre-restore-${Date.now()}`;
|
||||||
|
const restorePointPath = path.join(clonePath, '.versions', restorePointId);
|
||||||
|
fs.mkdirSync(restorePointPath, { recursive: true });
|
||||||
|
copyDirectory(clonePath, restorePointPath, ['.versions']);
|
||||||
|
|
||||||
|
// Copy version files back (overwrite)
|
||||||
|
copyDirectory(versionPath, clonePath, ['.versions'], true);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
restoredFrom: req.params.versionId,
|
||||||
|
backupCreated: restorePointId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao restaurar versão:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/clones/:id - Delete a clone
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const clonePath = path.join(BASE_DIR, req.params.id);
|
||||||
|
|
||||||
|
if (!fs.existsSync(clonePath)) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Clone não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(clonePath, { recursive: true, force: true });
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Clone removido' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao remover clone:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function findFiles(dir, extension) {
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
function search(d) {
|
||||||
|
if (!fs.existsSync(d)) return;
|
||||||
|
for (const item of fs.readdirSync(d)) {
|
||||||
|
const itemPath = path.join(d, item);
|
||||||
|
const stat = fs.statSync(itemPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory() && item !== '.versions' && !item.includes('node_modules')) {
|
||||||
|
search(itemPath);
|
||||||
|
} else if (item.endsWith(extension)) {
|
||||||
|
files.push(path.relative(dir, itemPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
search(dir);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyDirectory(src, dest, exclude = [], overwrite = true) {
|
||||||
|
if (!fs.existsSync(src)) return;
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
|
||||||
|
for (const item of fs.readdirSync(src)) {
|
||||||
|
if (exclude.includes(item)) continue;
|
||||||
|
|
||||||
|
const srcPath = path.join(src, item);
|
||||||
|
const destPath = path.join(dest, item);
|
||||||
|
const stat = fs.statSync(srcPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
copyDirectory(srcPath, destPath, exclude, overwrite);
|
||||||
|
} else {
|
||||||
|
if (overwrite || !fs.existsSync(destPath)) {
|
||||||
|
fs.copyFileSync(srcPath, destPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user