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 = `

No se encontraron modelos

Intenta con otros terminos de busqueda o sube uno nuevo.

`; 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 => `${t.name}` ).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 = `
${selectionMode ? `
${isSelected ? '' : ''}
` : ''} ${m.title}
${m.faces ?? '?'} caras

${m.title}

${m.author || 'Autor desconocido'}

${tagBadges}
${m.category || 'Sin categoria'}
${m.avg_rating ? `★ ${m.avg_rating.toFixed(1)}` : ''} ${formatSize(m.file_size)}
`; 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 = '

No hay tags aun

'; return; } container.innerHTML = allTags.map(t => `` ).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 = `

Error al cargar modelos

${e.message}

`; } } } 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 = `
Recientes: ${history.map(h => ` `).join('')}
`; } 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 = '
'; modal.classList.remove('hidden'); try { const data = await apiGet(`/models/${modelId}/estimate`); content.innerHTML = `

Volumen

${data.volume_cm3} cm³

Peso

${data.grams} g

Costo estimado

$${data.cost}

Tiempo

${data.estimated_time}

Basado en $${data.price_per_kg}/kg, densidad ${data.material_density} g/cm³

`; } catch (err) { content.innerHTML = `

Error: ${err.message}

`; } }; 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)); });