Files
stl-repo/static/js/app.js
Consultoria AS 0764be4945 feat: galeria muestra foto impresa + estimacion por placa en detalle
- Galeria: cards muestran imagen de referencia si existe, fallback a thumbnail 3D
- Detalle: nueva seccion 'Placas de impresion' con estimacion individual por parte
- Endpoint /estimate ahora acepta file_id para calcular por archivo
- Visualizacion de placa 220x220mm con proporcion de pieza y totales acumulados
2026-05-01 08:23:03 +00:00

399 lines
15 KiB
JavaScript

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('');
// Use reference image if available, fallback to 3D thumbnail
const refImage = (m.files || []).find(f => f.file_type === 'image');
const imgUrl = refImage
? `/images/${encodeURIComponent(refImage.filename)}`
: `/api/models/${m.id}/thumbnail`;
const imgLabel = refImage ? 'Foto' : 'Vista 3D';
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="${imgUrl}" alt="${m.title}" loading="lazy" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" onerror="this.onerror=null; this.src='/api/models/${m.id}/thumbnail';">
<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));
});