feat: implementar 12 mejoras, tests, docs y optimizaciones
- Fase A: license templates, search history, cost estimator - Fase B: import URL, bulk ZIP, batch download - Fase C: comparison mode, mesh validation, measurement tool - Fase D: cross-section clipping, overhang heatmap, layer animation - Refactor Pydantic/SQLAlchemy warnings - 24 tests pytest - README actualizado - WebP thumbnails, lazy loading, cache headers
This commit is contained in:
391
static/js/app.js
Normal file
391
static/js/app.js
Normal file
@@ -0,0 +1,391 @@
|
||||
let currentSkip = 0;
|
||||
const limit = 24;
|
||||
let currentFilters = {};
|
||||
let isLoading = false;
|
||||
let hasMore = true;
|
||||
let allTags = [];
|
||||
let selectionMode = false;
|
||||
let selectedIds = new Set();
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '—';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('es-ES', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function renderGrid(models, append = false) {
|
||||
const grid = document.getElementById('grid');
|
||||
const countEl = document.getElementById('model-count');
|
||||
|
||||
if (!append) grid.innerHTML = '';
|
||||
|
||||
if (models.length === 0 && !append) {
|
||||
grid.innerHTML = `
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-20 text-slate-500 animate-fade-in">
|
||||
<div class="w-16 h-16 rounded-full bg-slate-900 flex items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</div>
|
||||
<p class="text-lg font-medium mb-1">No se encontraron modelos</p>
|
||||
<p class="text-sm">Intenta con otros terminos de busqueda o <a href="/upload" class="text-cyan-400 hover:underline">sube uno nuevo</a>.</p>
|
||||
</div>`;
|
||||
if (countEl) countEl.textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
models.forEach((m, i) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'glass rounded-2xl overflow-hidden cursor-pointer card-hover animate-fade-in border border-white/5 relative group';
|
||||
card.style.animationDelay = `${i * 0.05}s`;
|
||||
card.onclick = (e) => {
|
||||
if (selectionMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleSelection(m.id);
|
||||
} else {
|
||||
window.location.href = '/model/' + m.id;
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = selectedIds.has(m.id);
|
||||
const tagBadges = (m.tags || []).slice(0, 3).map(t =>
|
||||
`<span class="px-1.5 py-0.5 rounded bg-cyan-500/10 text-cyan-400 text-[10px] font-medium">${t.name}</span>`
|
||||
).join('');
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="relative h-52 bg-slate-900 overflow-hidden">
|
||||
${selectionMode ? `
|
||||
<div class="absolute top-3 left-3 z-20">
|
||||
<div class="w-6 h-6 rounded-md border-2 flex items-center justify-center transition-colors ${isSelected ? 'bg-cyan-500 border-cyan-500' : 'border-white/50 bg-black/30'}">
|
||||
${isSelected ? '<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<img src="/api/models/${m.id}/thumbnail" alt="${m.title}" loading="lazy" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" onerror="this.style.display='none'; this.parentElement.innerHTML='<div class=\'w-full h-full flex items-center justify-center text-slate-600\'><svg class=\'w-10 h-10\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'1.5\' d=\'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4\'/></svg></div>';">
|
||||
<div class="absolute top-3 right-3">
|
||||
<span class="px-2.5 py-1 rounded-lg bg-black/50 backdrop-blur text-xs font-medium text-white border border-white/10">${m.faces ?? '?'} caras</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<h3 class="font-bold text-slate-100 mb-1 truncate">${m.title}</h3>
|
||||
<p class="text-sm text-slate-400 mb-2">${m.author || 'Autor desconocido'}</p>
|
||||
<div class="flex items-center gap-2 mb-3">${tagBadges}</div>
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
<span class="px-2 py-1 rounded-md bg-slate-800/80 border border-white/5">${m.category || 'Sin categoria'}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
${m.avg_rating ? `<span class="text-yellow-400">★ ${m.avg_rating.toFixed(1)}</span>` : ''}
|
||||
<span>${formatSize(m.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
try {
|
||||
allTags = await apiGet('/models/tags');
|
||||
renderTagCloud();
|
||||
} catch (e) {
|
||||
console.error('Error loading tags', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTagCloud() {
|
||||
const container = document.getElementById('tag-cloud');
|
||||
if (!container) return;
|
||||
if (allTags.length === 0) {
|
||||
container.innerHTML = '<p class="text-xs text-slate-600">No hay tags aun</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = allTags.map(t =>
|
||||
`<button onclick="filterByTag('${t.name}')" class="px-2.5 py-1 rounded-lg bg-slate-800/60 hover:bg-cyan-500/20 border border-white/5 hover:border-cyan-500/30 text-xs text-slate-400 hover:text-cyan-400 transition-all">${t.name} (${t.count})</button>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function filterByTag(tagName) {
|
||||
const tagInput = document.getElementById('tag');
|
||||
if (tagInput) tagInput.value = tagName;
|
||||
currentSkip = 0;
|
||||
hasMore = true;
|
||||
loadModels();
|
||||
}
|
||||
|
||||
async function loadModels(append = false) {
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
|
||||
const searchEl = document.getElementById('search');
|
||||
const categoryEl = document.getElementById('category');
|
||||
const tagEl = document.getElementById('tag');
|
||||
const sortEl = document.getElementById('sort-by');
|
||||
const minFacesEl = document.getElementById('min-faces');
|
||||
const maxFacesEl = document.getElementById('max-faces');
|
||||
const minDimEl = document.getElementById('min-dim');
|
||||
const maxDimEl = document.getElementById('max-dim');
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('skip', String(currentSkip));
|
||||
params.append('limit', String(limit));
|
||||
|
||||
if (searchEl && searchEl.value) params.append('search', searchEl.value);
|
||||
if (categoryEl && categoryEl.value) params.append('category', categoryEl.value);
|
||||
if (tagEl && tagEl.value) params.append('tag', tagEl.value);
|
||||
if (sortEl && sortEl.value) params.append('sort_by', sortEl.value);
|
||||
if (minFacesEl && minFacesEl.value) params.append('min_faces', minFacesEl.value);
|
||||
if (maxFacesEl && maxFacesEl.value) params.append('max_faces', maxFacesEl.value);
|
||||
if (minDimEl && minDimEl.value) params.append('min_width', minDimEl.value);
|
||||
if (maxDimEl && maxDimEl.value) params.append('max_width', maxDimEl.value);
|
||||
if (minDimEl && minDimEl.value) params.append('min_height', minDimEl.value);
|
||||
if (maxDimEl && maxDimEl.value) params.append('max_height', maxDimEl.value);
|
||||
if (minDimEl && minDimEl.value) params.append('min_depth', minDimEl.value);
|
||||
if (maxDimEl && maxDimEl.value) params.append('max_depth', maxDimEl.value);
|
||||
|
||||
try {
|
||||
const models = await apiGet('/models/?' + params.toString());
|
||||
if (models.length < limit) hasMore = false;
|
||||
renderGrid(models, append);
|
||||
|
||||
const countEl = document.getElementById('model-count');
|
||||
if (countEl && !append) countEl.textContent = models.length + (hasMore ? '+' : '');
|
||||
|
||||
const loadMoreBtn = document.getElementById('load-more');
|
||||
if (loadMoreBtn) loadMoreBtn.style.display = hasMore ? 'flex' : 'none';
|
||||
|
||||
if (!append && searchEl && searchEl.value.trim()) {
|
||||
saveSearchHistory(searchEl.value.trim());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (!append) {
|
||||
const grid = document.getElementById('grid');
|
||||
if (grid) {
|
||||
grid.innerHTML = `
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-20 text-red-400">
|
||||
<p class="text-lg font-medium">Error al cargar modelos</p>
|
||||
<p class="text-sm text-slate-500 mt-1">${e.message}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
currentSkip += limit;
|
||||
loadModels(true);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
['search', 'category', 'tag', 'min-faces', 'max-faces', 'min-dim', 'max-dim'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
const sortEl = document.getElementById('sort-by');
|
||||
if (sortEl) sortEl.value = 'newest';
|
||||
currentSkip = 0;
|
||||
hasMore = true;
|
||||
loadModels();
|
||||
renderSearchHistory();
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
// ====== Search History ======
|
||||
const SEARCH_HISTORY_KEY = 'stl_search_history';
|
||||
const MAX_HISTORY = 10;
|
||||
|
||||
function getSearchHistory() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '[]');
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function saveSearchHistory(query) {
|
||||
if (!query) return;
|
||||
let history = getSearchHistory();
|
||||
history = history.filter(h => h.toLowerCase() !== query.toLowerCase());
|
||||
history.unshift(query);
|
||||
if (history.length > MAX_HISTORY) history.pop();
|
||||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
|
||||
renderSearchHistory();
|
||||
}
|
||||
|
||||
function renderSearchHistory() {
|
||||
const container = document.getElementById('search-history');
|
||||
if (!container) return;
|
||||
const history = getSearchHistory();
|
||||
if (history.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center gap-2 flex-wrap mt-2">
|
||||
<span class="text-xs text-slate-500">Recientes:</span>
|
||||
${history.map(h => `
|
||||
<button onclick="applyHistory('${h.replace(/'/g, "\\'")}')" class="px-2 py-1 rounded-md bg-slate-800/60 hover:bg-cyan-500/20 border border-white/5 text-xs text-slate-400 hover:text-cyan-400 transition-all">${h}</button>
|
||||
`).join('')}
|
||||
<button onclick="clearHistory()" class="text-xs text-slate-600 hover:text-red-400 transition-colors ml-auto">Limpiar</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
window.applyHistory = function(query) {
|
||||
const searchEl = document.getElementById('search');
|
||||
if (searchEl) searchEl.value = query;
|
||||
currentSkip = 0; hasMore = true;
|
||||
loadModels();
|
||||
};
|
||||
|
||||
window.clearHistory = function() {
|
||||
localStorage.removeItem(SEARCH_HISTORY_KEY);
|
||||
renderSearchHistory();
|
||||
};
|
||||
|
||||
// ====== Batch Selection ======
|
||||
window.toggleSelectionMode = function() {
|
||||
selectionMode = !selectionMode;
|
||||
const btn = document.getElementById('btn-select-mode');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', selectionMode);
|
||||
btn.classList.toggle('text-cyan-400', selectionMode);
|
||||
btn.textContent = selectionMode ? 'Cancelar' : 'Seleccionar';
|
||||
}
|
||||
loadModels(false);
|
||||
updateSelectionBar();
|
||||
};
|
||||
|
||||
function toggleSelection(id) {
|
||||
if (selectedIds.has(id)) {
|
||||
selectedIds.delete(id);
|
||||
} else {
|
||||
selectedIds.add(id);
|
||||
}
|
||||
loadModels(false);
|
||||
updateSelectionBar();
|
||||
}
|
||||
|
||||
function updateSelectionBar() {
|
||||
const bar = document.getElementById('selection-bar');
|
||||
if (!bar) return;
|
||||
if (selectedIds.size === 0) {
|
||||
bar.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
bar.classList.remove('hidden');
|
||||
const countEl = document.getElementById('selection-count');
|
||||
if (countEl) countEl.textContent = selectedIds.size;
|
||||
}
|
||||
|
||||
window.batchDownload = async function() {
|
||||
if (selectedIds.size === 0) return;
|
||||
try {
|
||||
const ids = Array.from(selectedIds);
|
||||
const response = await fetch('/api/models/batch-download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ids)
|
||||
});
|
||||
if (!response.ok) throw new Error('Error al descargar');
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'batch_download.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`Descargando ${ids.length} modelo(s)`, 'success');
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.clearSelection = function() {
|
||||
selectedIds.clear();
|
||||
selectionMode = false;
|
||||
const btn = document.getElementById('btn-select-mode');
|
||||
if (btn) {
|
||||
btn.classList.remove('bg-cyan-500/20', 'text-cyan-400');
|
||||
btn.textContent = 'Seleccionar';
|
||||
}
|
||||
loadModels(false);
|
||||
updateSelectionBar();
|
||||
};
|
||||
|
||||
// ====== Cost Estimator Modal ======
|
||||
window.openEstimator = async function(modelId) {
|
||||
const modal = document.getElementById('estimator-modal');
|
||||
const content = document.getElementById('estimator-content');
|
||||
if (!modal || !content) return;
|
||||
content.innerHTML = '<div class="flex items-center justify-center py-8"><div class="w-8 h-8 border-4 border-slate-700 border-t-cyan-500 rounded-full animate-spin"></div></div>';
|
||||
modal.classList.remove('hidden');
|
||||
try {
|
||||
const data = await apiGet(`/models/${modelId}/estimate`);
|
||||
content.innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-3 rounded-xl bg-slate-900/50 border border-white/5 text-center">
|
||||
<p class="text-xs text-slate-500 uppercase">Volumen</p>
|
||||
<p class="text-lg font-bold text-slate-200">${data.volume_cm3} cm³</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-slate-900/50 border border-white/5 text-center">
|
||||
<p class="text-xs text-slate-500 uppercase">Peso</p>
|
||||
<p class="text-lg font-bold text-slate-200">${data.grams} g</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-slate-900/50 border border-white/5 text-center">
|
||||
<p class="text-xs text-slate-500 uppercase">Costo estimado</p>
|
||||
<p class="text-lg font-bold text-cyan-400">$${data.cost}</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-slate-900/50 border border-white/5 text-center">
|
||||
<p class="text-xs text-slate-500 uppercase">Tiempo</p>
|
||||
<p class="text-lg font-bold text-slate-200">${data.estimated_time}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-4 text-center">Basado en $${data.price_per_kg}/kg, densidad ${data.material_density} g/cm³</p>
|
||||
`;
|
||||
} catch (err) {
|
||||
content.innerHTML = `<p class="text-red-400 text-center py-4">Error: ${err.message}</p>`;
|
||||
}
|
||||
};
|
||||
|
||||
window.closeEstimator = function() {
|
||||
const modal = document.getElementById('estimator-modal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTags();
|
||||
loadModels();
|
||||
renderSearchHistory();
|
||||
|
||||
const searchEl = document.getElementById('search');
|
||||
const categoryEl = document.getElementById('category');
|
||||
const tagEl = document.getElementById('tag');
|
||||
const sortEl = document.getElementById('sort-by');
|
||||
const minFacesEl = document.getElementById('min-faces');
|
||||
const maxFacesEl = document.getElementById('max-faces');
|
||||
const minDimEl = document.getElementById('min-dim');
|
||||
const maxDimEl = document.getElementById('max-dim');
|
||||
|
||||
if (searchEl) searchEl.addEventListener('input', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
if (categoryEl) categoryEl.addEventListener('change', () => { currentSkip = 0; hasMore = true; loadModels(); });
|
||||
if (tagEl) tagEl.addEventListener('input', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
if (sortEl) sortEl.addEventListener('change', () => { currentSkip = 0; hasMore = true; loadModels(); });
|
||||
if (minFacesEl) minFacesEl.addEventListener('change', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
if (maxFacesEl) maxFacesEl.addEventListener('change', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
if (minDimEl) minDimEl.addEventListener('change', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
if (maxDimEl) maxDimEl.addEventListener('change', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
});
|
||||
Reference in New Issue
Block a user