- 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
408 lines
16 KiB
JavaScript
408 lines
16 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
// Tabs
|
|
const tabUpload = document.getElementById('tab-upload');
|
|
const tabUrl = document.getElementById('tab-url');
|
|
const tabZip = document.getElementById('tab-zip');
|
|
const panelUpload = document.getElementById('panel-upload');
|
|
const panelUrl = document.getElementById('panel-url');
|
|
const panelZip = document.getElementById('panel-zip');
|
|
|
|
function showPanel(name) {
|
|
[panelUpload, panelUrl, panelZip].forEach(p => p && p.classList.add('hidden'));
|
|
[tabUpload, tabUrl, tabZip].forEach(t => {
|
|
if (!t) return;
|
|
t.classList.remove('bg-cyan-500/20', 'text-cyan-400', 'border-cyan-500/30');
|
|
t.classList.add('bg-slate-800', 'border-white/10', 'text-slate-400');
|
|
});
|
|
const activeTab = name === 'upload' ? tabUpload : name === 'url' ? tabUrl : tabZip;
|
|
const activePanel = name === 'upload' ? panelUpload : name === 'url' ? panelUrl : panelZip;
|
|
if (activeTab) {
|
|
activeTab.classList.remove('bg-slate-800', 'border-white/10', 'text-slate-400');
|
|
activeTab.classList.add('bg-cyan-500/20', 'text-cyan-400', 'border-cyan-500/30');
|
|
}
|
|
if (activePanel) activePanel.classList.remove('hidden');
|
|
}
|
|
|
|
if (tabUpload) tabUpload.addEventListener('click', () => showPanel('upload'));
|
|
if (tabUrl) tabUrl.addEventListener('click', () => showPanel('url'));
|
|
if (tabZip) tabZip.addEventListener('click', () => showPanel('zip'));
|
|
|
|
// License template selector
|
|
const licenseSelect = document.getElementById('license-select');
|
|
const licenseInput = document.getElementById('license');
|
|
if (licenseSelect && licenseInput) {
|
|
licenseSelect.addEventListener('change', () => {
|
|
if (licenseSelect.value === 'custom') {
|
|
licenseInput.classList.remove('hidden');
|
|
licenseInput.focus();
|
|
} else {
|
|
licenseInput.classList.add('hidden');
|
|
licenseInput.value = licenseSelect.value;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ====== NORMAL UPLOAD ======
|
|
const dropZone = document.getElementById('drop-zone');
|
|
const fileInput = document.getElementById('file-input');
|
|
const fileNameDisplay = document.getElementById('file-name');
|
|
const uploadForm = document.getElementById('upload-form');
|
|
const submitBtn = document.getElementById('submit-btn');
|
|
const btnText = document.getElementById('btn-text');
|
|
const btnIcon = document.getElementById('btn-icon');
|
|
const tagsInput = document.getElementById('tags');
|
|
const tagsSuggestions = document.getElementById('tags-suggestions');
|
|
const partsList = document.getElementById('parts-list');
|
|
const imagesDropZone = document.getElementById('images-drop-zone');
|
|
const imagesInput = document.getElementById('images-input');
|
|
const imagesNameDisplay = document.getElementById('images-name');
|
|
|
|
let selectedFiles = [];
|
|
let selectedImages = [];
|
|
let allTags = [];
|
|
|
|
loadExistingTags();
|
|
|
|
async function loadExistingTags() {
|
|
try {
|
|
allTags = await apiGet('/models/tags');
|
|
} catch (e) {
|
|
console.error('Could not load tags', e);
|
|
}
|
|
}
|
|
|
|
// Tag autocomplete
|
|
if (tagsInput && tagsSuggestions) {
|
|
tagsInput.addEventListener('input', () => {
|
|
const val = tagsInput.value.toLowerCase();
|
|
const lastTag = val.split(',').pop().trim();
|
|
if (!lastTag || lastTag.length < 1) {
|
|
tagsSuggestions.style.display = 'none';
|
|
return;
|
|
}
|
|
const matches = allTags.filter(t => t.name.includes(lastTag) && !val.includes(t.name)).slice(0, 5);
|
|
if (matches.length === 0) {
|
|
tagsSuggestions.style.display = 'none';
|
|
return;
|
|
}
|
|
tagsSuggestions.innerHTML = matches.map(t =>
|
|
`<div class="px-3 py-2 hover:bg-cyan-500/20 cursor-pointer text-sm text-slate-300 hover:text-cyan-400 transition-colors" onclick="selectTag('${t.name}')">${t.name}</div>`
|
|
).join('');
|
|
tagsSuggestions.style.display = 'block';
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (!tagsInput.contains(e.target) && !tagsSuggestions.contains(e.target)) {
|
|
tagsSuggestions.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
window.selectTag = function(name) {
|
|
if (!tagsInput) return;
|
|
const parts = tagsInput.value.split(',');
|
|
parts.pop();
|
|
parts.push(name);
|
|
tagsInput.value = parts.join(', ') + ', ';
|
|
if (tagsSuggestions) tagsSuggestions.style.display = 'none';
|
|
tagsInput.focus();
|
|
};
|
|
|
|
// 3D Files drop zone
|
|
if (dropZone && fileInput) {
|
|
dropZone.addEventListener('click', (e) => {
|
|
if (e.target !== fileInput) fileInput.click();
|
|
});
|
|
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
dropZone.addEventListener(eventName, (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}, false);
|
|
});
|
|
|
|
['dragenter', 'dragover'].forEach(eventName => {
|
|
dropZone.addEventListener(eventName, () => dropZone.classList.add('drop-active'), false);
|
|
});
|
|
|
|
['dragleave', 'drop'].forEach(eventName => {
|
|
dropZone.addEventListener(eventName, () => dropZone.classList.remove('drop-active'), false);
|
|
});
|
|
|
|
dropZone.addEventListener('drop', (e) => {
|
|
const files = Array.from(e.dataTransfer.files).filter(f =>
|
|
f.name.toLowerCase().endsWith('.stl') || f.name.toLowerCase().endsWith('.3mf')
|
|
);
|
|
if (files.length) handleFiles(files);
|
|
}, false);
|
|
|
|
fileInput.addEventListener('change', () => {
|
|
if (fileInput.files.length) handleFiles(Array.from(fileInput.files));
|
|
});
|
|
}
|
|
|
|
function handleFiles(files) {
|
|
selectedFiles = files;
|
|
renderPartsList();
|
|
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
|
if (fileNameDisplay) {
|
|
fileNameDisplay.innerHTML = `<span class="text-cyan-400">${files.length} archivo(s)</span> <span class="text-slate-500">(${(totalSize / 1024).toFixed(1)} KB)</span>`;
|
|
}
|
|
showToast(`${files.length} archivo(s) 3D seleccionado(s)`, 'success');
|
|
}
|
|
|
|
function renderPartsList() {
|
|
if (!partsList) return;
|
|
if (selectedFiles.length === 0) {
|
|
partsList.innerHTML = '';
|
|
return;
|
|
}
|
|
partsList.innerHTML = selectedFiles.map((f, i) => `
|
|
<div class="flex items-center gap-3 p-3 rounded-xl bg-slate-900/40 border border-white/5">
|
|
<div class="w-8 h-8 rounded-lg bg-cyan-500/10 flex items-center justify-center text-cyan-400 text-xs font-bold">${i + 1}</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium truncate">${f.name}</p>
|
|
<p class="text-xs text-slate-500">${(f.size / 1024).toFixed(1)} KB</p>
|
|
</div>
|
|
<input type="text" placeholder="Nombre de parte" data-idx="${i}" class="part-name-input px-3 py-1.5 rounded-lg bg-slate-800 border border-white/10 text-sm w-32 focus:border-cyan-500 focus:outline-none placeholder:text-slate-600">
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Images drop zone
|
|
if (imagesDropZone && imagesInput) {
|
|
imagesDropZone.addEventListener('click', (e) => {
|
|
if (e.target !== imagesInput) imagesInput.click();
|
|
});
|
|
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
imagesDropZone.addEventListener(eventName, (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}, false);
|
|
});
|
|
|
|
['dragenter', 'dragover'].forEach(eventName => {
|
|
imagesDropZone.addEventListener(eventName, () => imagesDropZone.classList.add('drop-active'), false);
|
|
});
|
|
|
|
['dragleave', 'drop'].forEach(eventName => {
|
|
imagesDropZone.addEventListener(eventName, () => imagesDropZone.classList.remove('drop-active'), false);
|
|
});
|
|
|
|
imagesDropZone.addEventListener('drop', (e) => {
|
|
const files = Array.from(e.dataTransfer.files).filter(f =>
|
|
f.name.toLowerCase().endsWith('.jpg') || f.name.toLowerCase().endsWith('.jpeg') || f.name.toLowerCase().endsWith('.png')
|
|
);
|
|
if (files.length) handleImages(files);
|
|
}, false);
|
|
|
|
imagesInput.addEventListener('change', () => {
|
|
if (imagesInput.files.length) handleImages(Array.from(imagesInput.files));
|
|
});
|
|
}
|
|
|
|
function handleImages(files) {
|
|
selectedImages = files;
|
|
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
|
if (imagesNameDisplay) {
|
|
imagesNameDisplay.innerHTML = `<span class="text-cyan-400">${files.length} imagen(es)</span> <span class="text-slate-500">(${(totalSize / 1024).toFixed(1)} KB)</span>`;
|
|
}
|
|
showToast(`${files.length} imagen(es) seleccionada(s)`, 'success');
|
|
}
|
|
|
|
if (uploadForm) {
|
|
uploadForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
if (!selectedFiles.length) {
|
|
showToast('Selecciona al menos un archivo 3D', 'error');
|
|
dropZone.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
dropZone.classList.add('animate-pulse');
|
|
setTimeout(() => dropZone.classList.remove('animate-pulse'), 1000);
|
|
return;
|
|
}
|
|
|
|
const titleEl = document.getElementById('title');
|
|
if (!titleEl || !titleEl.value.trim()) {
|
|
showToast('El titulo es obligatorio', 'error');
|
|
titleEl.focus();
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
selectedFiles.forEach(f => formData.append('files', f));
|
|
selectedImages.forEach(f => formData.append('images', f));
|
|
formData.append('title', titleEl.value.trim());
|
|
|
|
const descEl = document.getElementById('description');
|
|
const authorEl = document.getElementById('author');
|
|
const licenseEl = document.getElementById('license');
|
|
const tagsEl = document.getElementById('tags');
|
|
const catEl = document.getElementById('category');
|
|
|
|
if (descEl) formData.append('description', descEl.value);
|
|
if (authorEl) formData.append('author', authorEl.value);
|
|
if (licenseEl) formData.append('license', licenseEl.value);
|
|
if (tagsEl) formData.append('tags', tagsEl.value);
|
|
if (catEl) formData.append('category', catEl.value);
|
|
|
|
// Collect part names
|
|
const partNameInputs = document.querySelectorAll('.part-name-input');
|
|
const partNames = {};
|
|
partNameInputs.forEach(input => {
|
|
const idx = input.getAttribute('data-idx');
|
|
if (input.value.trim()) {
|
|
partNames[idx] = input.value.trim();
|
|
}
|
|
});
|
|
if (Object.keys(partNames).length > 0) {
|
|
formData.append('part_names', JSON.stringify(partNames));
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const result = await apiPostForm('/models/', formData);
|
|
showToast('Modelo subido correctamente', 'success');
|
|
window.location.href = '/model/' + result.id;
|
|
} catch (err) {
|
|
console.error(err);
|
|
if (err.message.includes('already exists')) {
|
|
showToast('Este archivo ya existe en el repositorio', 'error');
|
|
} else {
|
|
showToast('Error al subir: ' + err.message, 'error');
|
|
}
|
|
setLoading(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ====== IMPORT URL ======
|
|
const urlForm = document.getElementById('url-form');
|
|
if (urlForm) {
|
|
urlForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const urlEl = document.getElementById('url-input');
|
|
const titleEl = document.getElementById('url-title');
|
|
if (!urlEl || !urlEl.value.trim() || !titleEl || !titleEl.value.trim()) {
|
|
showToast('URL y titulo son obligatorios', 'error');
|
|
return;
|
|
}
|
|
const formData = new FormData();
|
|
formData.append('url', urlEl.value.trim());
|
|
formData.append('title', titleEl.value.trim());
|
|
const desc = document.getElementById('url-description');
|
|
const author = document.getElementById('url-author');
|
|
const license = document.getElementById('url-license');
|
|
const tags = document.getElementById('url-tags');
|
|
const cat = document.getElementById('url-category');
|
|
if (desc) formData.append('description', desc.value);
|
|
if (author) formData.append('author', author.value);
|
|
if (license) formData.append('license', license.value);
|
|
if (tags) formData.append('tags', tags.value);
|
|
if (cat) formData.append('category', cat.value);
|
|
|
|
const btn = urlForm.querySelector('button[type="submit"]');
|
|
const orig = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = 'Importando...';
|
|
try {
|
|
const result = await apiPostForm('/models/import-url', formData);
|
|
showToast('Modelo importado correctamente', 'success');
|
|
window.location.href = '/model/' + result.id;
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message, 'error');
|
|
btn.disabled = false;
|
|
btn.innerHTML = orig;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ====== BULK ZIP ======
|
|
const zipDropZone = document.getElementById('zip-drop-zone');
|
|
const zipInput = document.getElementById('zip-input');
|
|
const zipNameDisplay = document.getElementById('zip-name');
|
|
const zipForm = document.getElementById('zip-form');
|
|
let selectedZip = null;
|
|
|
|
if (zipDropZone && zipInput) {
|
|
zipDropZone.addEventListener('click', (e) => {
|
|
if (e.target !== zipInput) zipInput.click();
|
|
});
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
zipDropZone.addEventListener(eventName, (e) => { e.preventDefault(); e.stopPropagation(); }, false);
|
|
});
|
|
['dragenter', 'dragover'].forEach(eventName => {
|
|
zipDropZone.addEventListener(eventName, () => zipDropZone.classList.add('drop-active'), false);
|
|
});
|
|
['dragleave', 'drop'].forEach(eventName => {
|
|
zipDropZone.addEventListener(eventName, () => zipDropZone.classList.remove('drop-active'), false);
|
|
});
|
|
zipDropZone.addEventListener('drop', (e) => {
|
|
const f = Array.from(e.dataTransfer.files).find(fi => fi.name.toLowerCase().endsWith('.zip'));
|
|
if (f) handleZip(f);
|
|
}, false);
|
|
zipInput.addEventListener('change', () => {
|
|
if (zipInput.files.length) handleZip(zipInput.files[0]);
|
|
});
|
|
}
|
|
|
|
function handleZip(file) {
|
|
selectedZip = file;
|
|
if (zipNameDisplay) {
|
|
zipNameDisplay.innerHTML = `<span class="text-cyan-400">${file.name}</span> <span class="text-slate-500">(${(file.size / 1024).toFixed(1)} KB)</span>`;
|
|
}
|
|
showToast('ZIP seleccionado', 'success');
|
|
}
|
|
|
|
if (zipForm) {
|
|
zipForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
if (!selectedZip) {
|
|
showToast('Selecciona un archivo ZIP', 'error');
|
|
return;
|
|
}
|
|
const formData = new FormData();
|
|
formData.append('zip_file', selectedZip);
|
|
const desc = document.getElementById('zip-description');
|
|
const author = document.getElementById('zip-author');
|
|
const license = document.getElementById('zip-license');
|
|
const tags = document.getElementById('zip-tags');
|
|
const cat = document.getElementById('zip-category');
|
|
if (desc) formData.append('description', desc.value);
|
|
if (author) formData.append('author', author.value);
|
|
if (license) formData.append('license', license.value);
|
|
if (tags) formData.append('tags', tags.value);
|
|
if (cat) formData.append('category', cat.value);
|
|
|
|
const btn = zipForm.querySelector('button[type="submit"]');
|
|
const orig = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = 'Procesando...';
|
|
try {
|
|
const results = await apiPostForm('/models/bulk-zip', formData);
|
|
showToast(`${results.length} modelo(s) importado(s)`, 'success');
|
|
setTimeout(() => window.location.href = '/', 1000);
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message, 'error');
|
|
btn.disabled = false;
|
|
btn.innerHTML = orig;
|
|
}
|
|
});
|
|
}
|
|
|
|
function setLoading(v) {
|
|
if (!submitBtn) return;
|
|
submitBtn.disabled = v;
|
|
if (v) {
|
|
btnText.textContent = 'Subiendo...';
|
|
btnIcon.innerHTML = '<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>';
|
|
btnIcon.classList.add('animate-spin');
|
|
} else {
|
|
btnText.textContent = 'Subir Modelo';
|
|
btnIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>';
|
|
btnIcon.classList.remove('animate-spin');
|
|
}
|
|
}
|
|
});
|