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:
101
static/css/style.css
Normal file
101
static/css/style.css
Normal file
@@ -0,0 +1,101 @@
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.light-mode .glass {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-6px) scale(1.01);
|
||||
box-shadow: 0 20px 40px -10px rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
|
||||
.light-mode .card-hover:hover {
|
||||
box-shadow: 0 20px 40px -10px rgba(6, 182, 212, 0.25);
|
||||
}
|
||||
|
||||
.drop-active {
|
||||
border-color: #06b6d4 !important;
|
||||
background: rgba(6, 182, 212, 0.08) !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: #0f172a; }
|
||||
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #475569; }
|
||||
|
||||
.light-mode ::-webkit-scrollbar-track { background: #f1f5f9; }
|
||||
.light-mode ::-webkit-scrollbar-thumb { background: #cbd5e1; }
|
||||
.light-mode ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
min-width: 280px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast.success { background: #059669; }
|
||||
.toast.error { background: #dc2626; }
|
||||
.toast.info { background: #2563eb; }
|
||||
|
||||
/* Light mode overrides via class on html */
|
||||
.light-mode body {
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.light-mode .bg-slate-950 { background-color: #f8fafc !important; }
|
||||
.light-mode .bg-slate-900 { background-color: #e2e8f0 !important; }
|
||||
.light-mode .bg-slate-900\/40 { background-color: rgba(226, 232, 240, 0.4) !important; }
|
||||
.light-mode .bg-slate-900\/50 { background-color: rgba(226, 232, 240, 0.5) !important; }
|
||||
.light-mode .bg-slate-900\/60 { background-color: rgba(226, 232, 240, 0.6) !important; }
|
||||
.light-mode .bg-slate-800 { background-color: #cbd5e1 !important; }
|
||||
.light-mode .bg-slate-800\/80 { background-color: rgba(203, 213, 225, 0.8) !important; }
|
||||
.light-mode .text-slate-100 { color: #1e293b !important; }
|
||||
.light-mode .text-slate-200 { color: #334155 !important; }
|
||||
.light-mode .text-slate-300 { color: #475569 !important; }
|
||||
.light-mode .text-slate-400 { color: #64748b !important; }
|
||||
.light-mode .text-slate-500 { color: #94a3b8 !important; }
|
||||
.light-mode .text-slate-600 { color: #cbd5e1 !important; }
|
||||
.light-mode .border-white\/5 { border-color: rgba(0,0,0,0.05) !important; }
|
||||
.light-mode .border-white\/10 { border-color: rgba(0,0,0,0.1) !important; }
|
||||
.light-mode .border-slate-700 { border-color: #cbd5e1 !important; }
|
||||
332
static/detail.html
Normal file
332
static/detail.html
Normal file
@@ -0,0 +1,332 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Detalle - STL Repository</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: { 850: '#172033', 950: '#020617' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<nav class="glass sticky top-0 z-50 border-b border-white/5">
|
||||
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-3 group">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center text-xl shadow-lg group-hover:scale-110 transition-transform">
|
||||
🖨️
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">STL Repository</h1>
|
||||
<p class="text-xs text-slate-400 -mt-0.5">Modelos 3D para imprimir</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex items-center">
|
||||
<button id="theme-toggle" class="p-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-slate-400 hover:text-yellow-400 transition-colors mr-2" title="Cambiar tema">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
</button>
|
||||
<a href="/" class="px-5 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-sm font-medium transition-colors">
|
||||
Volver a Galeria
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="max-w-7xl mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-5 gap-8">
|
||||
<!-- Viewer -->
|
||||
<div class="lg:col-span-3">
|
||||
<div class="glass rounded-2xl overflow-hidden border border-white/5 relative" style="height: 520px;">
|
||||
<div id="viewer" class="w-full h-full relative">
|
||||
<div id="viewer-loading" class="absolute inset-0 flex flex-col items-center justify-center text-slate-500 z-10">
|
||||
<div class="w-12 h-12 border-4 border-slate-700 border-t-cyan-500 rounded-full animate-spin mb-4"></div>
|
||||
<p class="text-sm">Cargando modelo 3D...</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Viewer Controls Overlay -->
|
||||
<div class="absolute top-4 left-4 flex flex-col gap-2 z-20">
|
||||
<div class="glass rounded-xl p-2 flex flex-col gap-1.5">
|
||||
<button onclick="setViewMode('solid')" id="btn-solid" class="p-2 rounded-lg bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30 transition-colors" title="Solido">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>
|
||||
</button>
|
||||
<button onclick="setViewMode('wireframe')" id="btn-wireframe" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Wireframe">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
|
||||
</button>
|
||||
<button onclick="toggleAxes()" id="btn-axes" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Mostrar ejes">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v16M4 4h16M4 4l16 16"></path></svg>
|
||||
</button>
|
||||
<button onclick="toggleBoundingBox()" id="btn-bbox" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Bounding box">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2z"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-2 flex flex-col gap-1.5">
|
||||
<button onclick="toggleMeasure()" id="btn-measure" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Medir">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path></svg>
|
||||
</button>
|
||||
<button onclick="toggleClip()" id="btn-clip" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Corte transversal">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
|
||||
</button>
|
||||
<button onclick="toggleOverhang()" id="btn-overhang" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Mapa de voladizos">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
</button>
|
||||
<button onclick="toggleLayerAnimation()" id="btn-layers" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Animacion de capas">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</button>
|
||||
<button onclick="openCompareModal()" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Comparar">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-2 flex flex-col gap-1.5">
|
||||
<button onclick="setCameraView('front')" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-cyan-400 transition-colors text-xs font-bold" title="Frontal">F</button>
|
||||
<button onclick="setCameraView('top')" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-cyan-400 transition-colors text-xs font-bold" title="Superior">S</button>
|
||||
<button onclick="setCameraView('side')" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-cyan-400 transition-colors text-xs font-bold" title="Lateral">L</button>
|
||||
<button onclick="setCameraView('iso')" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-cyan-400 transition-colors text-xs font-bold" title="Isometrico">I</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Clip Slider -->
|
||||
<div id="clip-controls" class="absolute bottom-4 left-1/2 -translate-x-1/2 glass rounded-xl px-4 py-2 border border-white/10 hidden flex items-center gap-3 z-20">
|
||||
<span class="text-xs text-slate-400 whitespace-nowrap">Plano de corte</span>
|
||||
<input type="range" id="clip-slider" min="-50" max="50" value="0" step="0.5" class="w-48 accent-cyan-500">
|
||||
<button onclick="toggleClip()" class="text-xs text-red-400 hover:text-red-300">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-3 text-xs text-slate-500 px-1">
|
||||
<span>Arrastra para rotar • Rueda para zoom</span>
|
||||
<span id="viewer-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta Panel -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div class="glass rounded-2xl p-6 border border-white/5">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 id="meta-title" class="text-2xl font-bold mb-1">—</h2>
|
||||
<p id="meta-author" class="text-slate-400 text-sm">—</p>
|
||||
<div id="meta-rating" class="mt-1"></div>
|
||||
</div>
|
||||
<button id="btn-edit" class="p-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-white/10 text-slate-400 hover:text-cyan-400 transition-colors" title="Editar modelo">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider">Descripcion</label>
|
||||
<p id="meta-desc" class="text-sm text-slate-300 mt-1 leading-relaxed">—</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider">Categoria</label>
|
||||
<p id="meta-category" class="text-sm font-medium text-cyan-400 mt-1">—</p>
|
||||
</div>
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider">Licencia</label>
|
||||
<p id="meta-license" class="text-sm font-medium text-slate-300 mt-1">—</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider">Tags</label>
|
||||
<div id="meta-tags" class="flex flex-wrap gap-2 mt-2">—</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5 text-center">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block">Caras</label>
|
||||
<p id="meta-faces" class="text-lg font-bold text-slate-200 mt-1">—</p>
|
||||
</div>
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5 text-center col-span-2">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block">Dimensiones</label>
|
||||
<p id="meta-dims" class="text-sm font-bold text-slate-200 mt-1">—</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation & Estimation -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button onclick="runValidation()" class="p-3 rounded-xl bg-slate-900/50 border border-white/5 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all text-center">
|
||||
<span class="text-xs text-slate-500 uppercase block mb-1">Validacion</span>
|
||||
<span class="text-sm font-medium text-cyan-400">Validar malla</span>
|
||||
</button>
|
||||
<button onclick="runEstimation()" class="p-3 rounded-xl bg-slate-900/50 border border-white/5 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all text-center">
|
||||
<span class="text-xs text-slate-500 uppercase block mb-1">Impresion</span>
|
||||
<span class="text-sm font-medium text-cyan-400">Estimar costo</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="validation-result" class="hidden p-4 rounded-xl bg-slate-900/50 border border-white/5 text-sm space-y-1"></div>
|
||||
<div id="estimation-result" class="hidden p-4 rounded-xl bg-slate-900/50 border border-white/5 text-sm space-y-1"></div>
|
||||
|
||||
<div class="pt-4 flex gap-3">
|
||||
<a id="btn-download" href="#" class="flex-1 px-5 py-3 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-sm shadow-lg shadow-cyan-500/20 transition-all hover:scale-[1.02] text-center flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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></svg>
|
||||
Descargar
|
||||
</a>
|
||||
<button id="btn-download-all" class="hidden px-5 py-3 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-white font-bold text-sm transition-all hover:scale-[1.02] flex items-center gap-2" title="Descargar todas las partes">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
|
||||
ZIP
|
||||
</button>
|
||||
<button id="btn-share" class="px-5 py-3 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-slate-300 font-bold text-sm transition-all hover:scale-[1.02] flex items-center gap-2" title="Compartir QR">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||
QR
|
||||
</button>
|
||||
<button id="btn-delete" class="px-5 py-3 rounded-xl bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 font-bold text-sm transition-all hover:scale-[1.02] flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collections -->
|
||||
<div class="glass rounded-2xl p-5 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-3">Colecciones</label>
|
||||
<select id="collection-select" class="w-full px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm mb-3 cursor-pointer">
|
||||
<option value="">Cargando...</option>
|
||||
</select>
|
||||
<form id="collection-form" class="flex gap-2">
|
||||
<input type="text" id="collection-name" placeholder="Nueva coleccion" class="flex-1 px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
<input type="text" id="collection-desc" placeholder="Desc" class="w-24 px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
<button type="submit" class="px-3 py-2 rounded-xl bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30 font-bold text-sm transition-colors">+</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Ratings -->
|
||||
<div id="ratings-section" class="glass rounded-2xl p-5 border border-white/5"></div>
|
||||
|
||||
<form id="rating-form" class="glass rounded-2xl p-5 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Valorar modelo</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="rating-stars" class="px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm cursor-pointer">
|
||||
<option value="5">★★★★★ (5)</option>
|
||||
<option value="4">★★★★☆ (4)</option>
|
||||
<option value="3">★★★☆☆ (3)</option>
|
||||
<option value="2">★★☆☆☆ (2)</option>
|
||||
<option value="1">★☆☆☆☆ (1)</option>
|
||||
</select>
|
||||
<button type="submit" class="px-4 py-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-sm shadow-lg shadow-cyan-500/20 transition-all">Enviar</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="glass rounded-2xl p-5 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-3">Comentarios</label>
|
||||
<div id="comments-list" class="space-y-3 mb-4 max-h-64 overflow-y-auto pr-1"></div>
|
||||
<form id="comment-form" class="space-y-2">
|
||||
<input type="text" id="comment-author" placeholder="Tu nombre (opcional)" class="w-full px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
<textarea id="comment-text" rows="2" placeholder="Escribe un comentario..." required class="w-full px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600 resize-none"></textarea>
|
||||
<button type="submit" class="w-full px-4 py-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-sm shadow-lg shadow-cyan-500/20 transition-all">Comentar</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Parts list -->
|
||||
<div id="parts-list"></div>
|
||||
|
||||
<!-- Images gallery -->
|
||||
<div id="images-gallery"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div id="edit-modal" class="hidden fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div class="glass rounded-2xl p-8 w-full max-w-lg border border-white/10 animate-fade-in">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold">Editar Modelo</h3>
|
||||
<button id="edit-close" class="p-2 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="edit-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Titulo</label>
|
||||
<input type="text" id="edit-title" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Descripcion</label>
|
||||
<textarea id="edit-description" rows="3" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all resize-none"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Autor</label>
|
||||
<input type="text" id="edit-author" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Licencia</label>
|
||||
<input type="text" id="edit-license" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Categoria</label>
|
||||
<select id="edit-category" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all cursor-pointer text-slate-300">
|
||||
<option value="">Sin categoria</option>
|
||||
<option value="Arte">Arte</option>
|
||||
<option value="Herramientas">Herramientas</option>
|
||||
<option value="Juguetes">Juguetes</option>
|
||||
<option value="Piezas">Piezas</option>
|
||||
<option value="Decoracion">Decoracion</option>
|
||||
<option value="Otros">Otros</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Tags</label>
|
||||
<input type="text" id="edit-tags" placeholder="Separados por comas" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4 flex gap-3">
|
||||
<button type="submit" class="flex-1 px-5 py-3 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold shadow-lg shadow-cyan-500/20 transition-all hover:scale-[1.02]">Guardar Cambios</button>
|
||||
<button type="button" id="edit-cancel" class="px-5 py-3 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 font-medium transition-colors">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Modal -->
|
||||
<div id="qr-modal" class="hidden fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div class="glass rounded-2xl p-8 w-full max-w-sm border border-white/10 animate-fade-in text-center">
|
||||
<h3 class="text-xl font-bold mb-4">Compartir modelo</h3>
|
||||
<p class="text-sm text-slate-400 mb-4">Escanea el codigo QR para abrir este modelo en tu dispositivo.</p>
|
||||
<img id="qr-image" src="" alt="QR Code" class="mx-auto rounded-xl border border-white/10 mb-4">
|
||||
<p id="qr-url" class="text-xs text-slate-500 break-all mb-4"></p>
|
||||
<button id="qr-close" class="px-6 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 font-medium transition-colors">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compare Modal -->
|
||||
<div id="compare-modal" class="hidden fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div class="glass rounded-2xl p-6 w-full max-w-lg border border-white/10 animate-fade-in">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold">Comparar modelo</h3>
|
||||
<button onclick="closeCompareModal()" class="p-2 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400 mb-3">Selecciona otro modelo para abrir una vista de comparacion lado a lado.</p>
|
||||
<div id="compare-list" class="max-h-64 overflow-y-auto space-y-2 pr-1">
|
||||
<p class="text-sm text-slate-500">Cargando modelos...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
180
static/index.html
Normal file
180
static/index.html
Normal file
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>STL Repository</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: { 850: '#172033', 950: '#020617' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<nav class="glass sticky top-0 z-50 border-b border-white/5">
|
||||
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-3 group">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center text-xl shadow-lg group-hover:scale-110 transition-transform">
|
||||
🖨️
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">STL Repository</h1>
|
||||
<p class="text-xs text-slate-400 -mt-0.5">Modelos 3D para imprimir</p>
|
||||
</div>
|
||||
</a>
|
||||
<button id="theme-toggle" class="p-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-slate-400 hover:text-yellow-400 transition-colors" title="Cambiar tema">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
</button>
|
||||
<a href="/upload" class="px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-semibold text-sm shadow-lg shadow-cyan-500/20 transition-all hover:scale-105 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||
Subir Modelo
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="max-w-7xl mx-auto px-6 py-8 flex gap-8">
|
||||
<!-- Sidebar -->
|
||||
<aside class="hidden lg:block w-64 shrink-0">
|
||||
<div class="glass rounded-2xl p-5 border border-white/5 sticky top-24">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-sm uppercase tracking-wider text-slate-400">Filtros</h3>
|
||||
<button onclick="resetFilters()" class="text-xs text-cyan-400 hover:text-cyan-300 transition-colors">Limpiar</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 mb-1.5 block">Ordenar por</label>
|
||||
<select id="sort-by" class="w-full px-3 py-2 rounded-lg bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm cursor-pointer">
|
||||
<option value="newest">Mas nuevos</option>
|
||||
<option value="oldest">Mas antiguos</option>
|
||||
<option value="most_downloaded">Mas descargados</option>
|
||||
<option value="largest">Mas grandes</option>
|
||||
<option value="most_faces">Mas caras</option>
|
||||
<option value="highest_rated">Mejor valorados</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 mb-1.5 block">Caras</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="number" id="min-faces" placeholder="Min" class="w-full px-3 py-2 rounded-lg bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
<input type="number" id="max-faces" placeholder="Max" class="w-full px-3 py-2 rounded-lg bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 mb-1.5 block">Dimension max (mm)</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="number" id="min-dim" placeholder="Min" class="w-full px-3 py-2 rounded-lg bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
<input type="number" id="max-dim" placeholder="Max" class="w-full px-3 py-2 rounded-lg bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-5 border-t border-white/5">
|
||||
<h3 class="font-bold text-sm uppercase tracking-wider text-slate-400 mb-3">Tags</h3>
|
||||
<div id="tag-cloud" class="flex flex-wrap gap-2">
|
||||
<p class="text-xs text-slate-600">Cargando...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold mb-1">Galeria de Modelos</h2>
|
||||
<p class="text-slate-400 text-sm">Explora, visualiza y descarga modelos 3D listos para imprimir.</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters bar -->
|
||||
<div class="glass rounded-2xl p-4 mb-6 flex flex-wrap gap-3 items-center">
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||
<input type="text" id="search" placeholder="Buscar modelos..." class="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 text-sm transition-all placeholder:text-slate-500">
|
||||
<div id="search-history" class="absolute left-0 right-0 top-full mt-1 z-20"></div>
|
||||
</div>
|
||||
<select id="category" class="px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 text-sm cursor-pointer min-w-[150px]">
|
||||
<option value="">Todas las categorias</option>
|
||||
<option value="Arte">Arte</option>
|
||||
<option value="Herramientas">Herramientas</option>
|
||||
<option value="Juguetes">Juguetes</option>
|
||||
<option value="Piezas">Piezas</option>
|
||||
<option value="Decoracion">Decoracion</option>
|
||||
<option value="Otros">Otros</option>
|
||||
</select>
|
||||
<div class="relative min-w-[160px]">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path></svg>
|
||||
<input type="text" id="tag" placeholder="Filtrar por tag..." class="w-full pl-9 pr-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 text-sm transition-all placeholder:text-slate-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats & Actions -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-sm text-slate-400"><span id="model-count" class="font-bold text-slate-200">0</span> modelos</span>
|
||||
<button id="btn-select-mode" onclick="toggleSelectionMode()" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-white/10 text-xs font-medium transition-colors">
|
||||
Seleccionar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selection Bar -->
|
||||
<div id="selection-bar" class="hidden mb-4 glass rounded-xl p-3 border border-cyan-500/20 flex items-center justify-between">
|
||||
<span class="text-sm text-slate-300"><span id="selection-count" class="font-bold text-cyan-400">0</span> seleccionados</span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="batchDownload()" class="px-4 py-2 rounded-lg bg-gradient-to-r from-cyan-500 to-blue-600 text-white text-xs font-bold shadow-lg shadow-cyan-500/20 transition-all hover:scale-105">
|
||||
Descargar ZIP
|
||||
</button>
|
||||
<button onclick="clearSelection()" class="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-white/10 text-xs font-medium transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div id="grid" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-20 text-slate-500">
|
||||
<div class="w-12 h-12 border-4 border-slate-700 border-t-cyan-500 rounded-full animate-spin mb-4"></div>
|
||||
<p>Cargando modelos...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
<div class="mt-8 flex justify-center">
|
||||
<button id="load-more" onclick="loadMore()" class="hidden px-6 py-3 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-sm font-medium transition-colors">
|
||||
Cargar mas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Estimator Modal -->
|
||||
<div id="estimator-modal" class="hidden fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div class="glass rounded-2xl p-6 w-full max-w-sm border border-white/10 animate-fade-in">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold">Estimacion de impresion</h3>
|
||||
<button onclick="closeEstimator()" class="p-2 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="estimator-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
static/js/api.js
Normal file
59
static/js/api.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateX(20px)';
|
||||
toast.style.transition = 'all 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(API_BASE + path);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiDelete(path) {
|
||||
const res = await fetch(API_BASE + path, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPostForm(path, formData) {
|
||||
const res = await fetch(API_BASE + path, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPut(path, data) {
|
||||
const res = await fetch(API_BASE + path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
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));
|
||||
});
|
||||
941
static/js/detail.js
Normal file
941
static/js/detail.js
Normal file
@@ -0,0 +1,941 @@
|
||||
let currentModel = null;
|
||||
let viewerMeshes = [];
|
||||
let scene, camera, renderer, controls, axesHelper, bboxHelper;
|
||||
let currentViewMode = 'solid';
|
||||
let axesVisible = false;
|
||||
let bboxVisible = false;
|
||||
let clipPlane = null;
|
||||
let clipEnabled = false;
|
||||
let measureMode = false;
|
||||
let measurePoints = [];
|
||||
let measureLine = null;
|
||||
let measureLabel = null;
|
||||
let overhangMode = false;
|
||||
let layerAnimating = false;
|
||||
let layerAnimationId = null;
|
||||
let originalColors = new Map();
|
||||
|
||||
function getModelId() {
|
||||
const parts = window.location.pathname.split('/');
|
||||
const id = parts.pop();
|
||||
return parseInt(id) || null;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modelId = getModelId();
|
||||
if (!modelId) {
|
||||
showToast('ID de modelo invalido', 'error');
|
||||
return;
|
||||
}
|
||||
loadDetail(modelId);
|
||||
|
||||
const editBtn = document.getElementById('btn-edit');
|
||||
const editModal = document.getElementById('edit-modal');
|
||||
const editClose = document.getElementById('edit-close');
|
||||
const editForm = document.getElementById('edit-form');
|
||||
const editCancel = document.getElementById('edit-cancel');
|
||||
|
||||
if (editBtn && editModal) {
|
||||
editBtn.addEventListener('click', () => {
|
||||
fillEditForm();
|
||||
editModal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
if (editClose && editModal) {
|
||||
editClose.addEventListener('click', () => editModal.classList.add('hidden'));
|
||||
editModal.addEventListener('click', (e) => {
|
||||
if (e.target === editModal) editModal.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
if (editCancel && editModal) {
|
||||
editCancel.addEventListener('click', () => editModal.classList.add('hidden'));
|
||||
}
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await saveEdit();
|
||||
});
|
||||
}
|
||||
|
||||
// QR modal
|
||||
const shareBtn = document.getElementById('btn-share');
|
||||
const qrModal = document.getElementById('qr-modal');
|
||||
const qrClose = document.getElementById('qr-close');
|
||||
if (shareBtn && qrModal) {
|
||||
shareBtn.addEventListener('click', () => {
|
||||
const qrImg = document.getElementById('qr-image');
|
||||
const qrUrl = document.getElementById('qr-url');
|
||||
const url = `${window.location.origin}/model/${modelId}`;
|
||||
if (qrImg) qrImg.src = `/api/models/${modelId}/qr`;
|
||||
if (qrUrl) qrUrl.textContent = url;
|
||||
qrModal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
if (qrClose && qrModal) {
|
||||
qrClose.addEventListener('click', () => qrModal.classList.add('hidden'));
|
||||
qrModal.addEventListener('click', (e) => {
|
||||
if (e.target === qrModal) qrModal.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// Rating form
|
||||
const ratingForm = document.getElementById('rating-form');
|
||||
if (ratingForm) {
|
||||
ratingForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const stars = parseInt(document.getElementById('rating-stars').value);
|
||||
if (!stars || stars < 1 || stars > 5) return;
|
||||
try {
|
||||
await apiPostForm(`/models/${modelId}/ratings?stars=${stars}`, new FormData());
|
||||
showToast('Valoracion enviada', 'success');
|
||||
loadDetail(modelId);
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Comment form
|
||||
const commentForm = document.getElementById('comment-form');
|
||||
if (commentForm) {
|
||||
commentForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const text = document.getElementById('comment-text').value.trim();
|
||||
const author = document.getElementById('comment-author').value.trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
const params = new URLSearchParams({ text });
|
||||
if (author) params.append('author_name', author);
|
||||
await apiPostForm(`/models/${modelId}/comments?${params.toString()}`, new FormData());
|
||||
showToast('Comentario agregado', 'success');
|
||||
document.getElementById('comment-text').value = '';
|
||||
loadDetail(modelId);
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Collection form
|
||||
const collForm = document.getElementById('collection-form');
|
||||
if (collForm) {
|
||||
collForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('collection-name').value.trim();
|
||||
const desc = document.getElementById('collection-desc').value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await apiPut('/models/collections', { name, description: desc });
|
||||
showToast('Coleccion creada', 'success');
|
||||
document.getElementById('collection-name').value = '';
|
||||
document.getElementById('collection-desc').value = '';
|
||||
loadCollections();
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Theme listener
|
||||
window.addEventListener('themechange', (e) => {
|
||||
if (scene) {
|
||||
scene.background = new THREE.Color(e.detail === 'light' ? 0xf8fafc : 0x0f172a);
|
||||
if (window.gridHelper) window.gridHelper.material.color.setHex(e.detail === 'light' ? 0xcbd5e1 : 0x1e293b);
|
||||
}
|
||||
});
|
||||
|
||||
// Clip slider
|
||||
const clipSlider = document.getElementById('clip-slider');
|
||||
if (clipSlider) {
|
||||
clipSlider.addEventListener('input', () => {
|
||||
if (clipPlane) {
|
||||
clipPlane.constant = parseFloat(clipSlider.value);
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDetail(modelId) {
|
||||
try {
|
||||
const model = await apiGet('/models/' + modelId);
|
||||
currentModel = model;
|
||||
renderMeta(model);
|
||||
renderParts(model);
|
||||
renderImages(model);
|
||||
renderRatings(model);
|
||||
renderComments(model);
|
||||
loadCollections();
|
||||
initViewer(model);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
document.querySelector('main').innerHTML = `
|
||||
<div class="max-w-xl mx-auto text-center py-20">
|
||||
<div class="w-20 h-20 rounded-full bg-slate-900 flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-slate-600" 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>
|
||||
<h2 class="text-2xl font-bold mb-2">Modelo no encontrado</h2>
|
||||
<p class="text-slate-400 mb-6">El modelo que buscas no existe o ha sido eliminado.</p>
|
||||
<a href="/" class="px-6 py-3 rounded-xl bg-cyan-500 hover:bg-cyan-400 text-white font-bold transition-colors">Volver a la galeria</a>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStars(avg, count) {
|
||||
if (!avg) return '<span class="text-xs text-slate-500">Sin valoraciones</span>';
|
||||
const full = Math.floor(avg);
|
||||
const half = avg - full >= 0.5;
|
||||
let html = '<span class="flex items-center gap-1">';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (i < full) {
|
||||
html += '<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>';
|
||||
} else if (i === full && half) {
|
||||
html += '<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><defs><linearGradient id="half"><stop offset="50%" stop-color="currentColor"/><stop offset="50%" stop-color="transparent"/></linearGradient></defs><path fill="url(#half)" d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>';
|
||||
} else {
|
||||
html += '<svg class="w-4 h-4 text-slate-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>';
|
||||
}
|
||||
}
|
||||
html += `<span class="text-xs text-slate-500 ml-1">${avg.toFixed(1)} (${count})</span></span>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderMeta(m) {
|
||||
const setText = (id, text) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text || '—';
|
||||
};
|
||||
|
||||
setText('meta-title', m.title);
|
||||
setText('meta-desc', m.description);
|
||||
setText('meta-author', m.author ? 'Por ' + m.author : 'Autor desconocido');
|
||||
setText('meta-license', m.license);
|
||||
setText('meta-category', m.category);
|
||||
setText('meta-faces', m.faces ?? '—');
|
||||
|
||||
const ratingEl = document.getElementById('meta-rating');
|
||||
if (ratingEl) ratingEl.innerHTML = renderStars(m.avg_rating, (m.ratings || []).length);
|
||||
|
||||
const dimsEl = document.getElementById('meta-dims');
|
||||
if (dimsEl) {
|
||||
dimsEl.textContent = (m.width && m.height && m.depth)
|
||||
? `${m.width.toFixed(1)} x ${m.height.toFixed(1)} x ${m.depth.toFixed(1)} mm`
|
||||
: '—';
|
||||
}
|
||||
|
||||
const tagsEl = document.getElementById('meta-tags');
|
||||
if (tagsEl) {
|
||||
if (m.tags && m.tags.length > 0) {
|
||||
tagsEl.innerHTML = m.tags.map(t =>
|
||||
`<a href="/?tag=${encodeURIComponent(t.name)}" class="px-2.5 py-1 rounded-lg bg-cyan-500/10 text-cyan-400 text-xs font-medium border border-cyan-500/20 hover:bg-cyan-500/20 transition-colors">${t.name}</a>`
|
||||
).join('');
|
||||
} else {
|
||||
tagsEl.innerHTML = '<span class="text-slate-500 text-sm">—</span>';
|
||||
}
|
||||
}
|
||||
|
||||
const dlBtn = document.getElementById('btn-download');
|
||||
if (dlBtn) dlBtn.href = '/api/models/' + m.id + '/download';
|
||||
|
||||
const delBtn = document.getElementById('btn-delete');
|
||||
if (delBtn) {
|
||||
delBtn.onclick = async () => {
|
||||
if (!confirm('Eliminar este modelo permanentemente?')) return;
|
||||
try {
|
||||
await apiDelete('/models/' + m.id);
|
||||
showToast('Modelo eliminado', 'success');
|
||||
setTimeout(() => window.location.href = '/', 800);
|
||||
} catch (e) {
|
||||
showToast('Error al eliminar: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const dlAllBtn = document.getElementById('btn-download-all');
|
||||
if (dlAllBtn) {
|
||||
const modelFiles = (m.files || []).filter(f => f.file_type === 'stl' || f.file_type === '3mf');
|
||||
if (modelFiles.length > 1) {
|
||||
dlAllBtn.classList.remove('hidden');
|
||||
dlAllBtn.onclick = () => {
|
||||
window.location.href = '/api/models/' + m.id + '/download-all';
|
||||
};
|
||||
} else {
|
||||
dlAllBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderParts(m) {
|
||||
const container = document.getElementById('parts-list');
|
||||
if (!container) return;
|
||||
const modelFiles = (m.files || []).filter(f => f.file_type === 'stl' || f.file_type === '3mf');
|
||||
if (modelFiles.length <= 1) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const colors = ['#22d3ee', '#f472b6', '#a3e635', '#fbbf24', '#a78bfa', '#fb923c'];
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="mb-3">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Partes (${modelFiles.length})</label>
|
||||
<div class="space-y-2">
|
||||
${modelFiles.map((f, i) => `
|
||||
<div class="flex items-center gap-3 p-2.5 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<input type="checkbox" id="part-${f.id}" checked onchange="togglePart(${f.id})" class="w-4 h-4 rounded border-slate-600 text-cyan-500 focus:ring-cyan-500/20 bg-slate-800">
|
||||
<div class="w-3 h-3 rounded-full" style="background:${colors[i % colors.length]}"></div>
|
||||
<span class="text-sm flex-1 truncate">${f.part_name || f.filename}</span>
|
||||
<a href="/api/models/${m.id}/download?file_id=${f.id}" class="text-xs text-cyan-400 hover:text-cyan-300 transition-colors" title="Descargar">⬇</a>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderImages(m) {
|
||||
const container = document.getElementById('images-gallery');
|
||||
if (!container) return;
|
||||
const images = (m.files || []).filter(f => f.file_type === 'image');
|
||||
if (images.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div class="mb-3">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Imagenes de referencia</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
${images.map(img => `
|
||||
<a href="/images/${encodeURIComponent(img.filename)}" target="_blank" class="rounded-xl overflow-hidden border border-white/5 hover:border-cyan-500/30 transition-colors">
|
||||
<img src="/images/${encodeURIComponent(img.filename)}" alt="${img.filename}" class="w-full h-24 object-cover hover:scale-105 transition-transform">
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRatings(m) {
|
||||
const container = document.getElementById('ratings-section');
|
||||
if (!container) return;
|
||||
const ratings = m.ratings || [];
|
||||
container.innerHTML = `
|
||||
<div class="mb-4">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Valoraciones</label>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
${renderStars(m.avg_rating, ratings.length)}
|
||||
</div>
|
||||
${ratings.length > 0 ? `
|
||||
<div class="space-y-2 max-h-32 overflow-y-auto pr-1">
|
||||
${ratings.slice(0, 5).map(r => `
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-yellow-400">${'★'.repeat(r.stars)}${'☆'.repeat(5 - r.stars)}</span>
|
||||
<span class="text-slate-500 text-xs">${new Date(r.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '<p class="text-sm text-slate-500">Aun no hay valoraciones</p>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderComments(m) {
|
||||
const container = document.getElementById('comments-list');
|
||||
if (!container) return;
|
||||
const comments = m.comments || [];
|
||||
if (comments.length === 0) {
|
||||
container.innerHTML = '<p class="text-sm text-slate-500">Aun no hay comentarios. Se el primero!</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = comments.map(c => `
|
||||
<div class="p-3 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium">${c.author_name || 'Anonimo'}</span>
|
||||
<span class="text-xs text-slate-500">${new Date(c.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-300">${c.text}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadCollections() {
|
||||
const select = document.getElementById('collection-select');
|
||||
if (!select) return;
|
||||
try {
|
||||
const collections = await apiGet('/models/collections/all');
|
||||
const modelId = getModelId();
|
||||
select.innerHTML = '<option value="">Seleccionar coleccion...</option>' +
|
||||
collections.map(c => `<option value="${c.id}">${c.name} (${c.model_count})</option>`).join('');
|
||||
select.onchange = async () => {
|
||||
if (!select.value) return;
|
||||
try {
|
||||
await apiPostForm(`/models/collections/${select.value}/add/${modelId}`, new FormData());
|
||||
showToast('Agregado a coleccion', 'success');
|
||||
select.value = '';
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function fillEditForm() {
|
||||
if (!currentModel) return;
|
||||
const fields = ['title', 'description', 'author', 'license', 'category'];
|
||||
fields.forEach(f => {
|
||||
const el = document.getElementById('edit-' + f);
|
||||
if (el) el.value = currentModel[f] || '';
|
||||
});
|
||||
const tagsEl = document.getElementById('edit-tags');
|
||||
if (tagsEl) tagsEl.value = (currentModel.tags || []).map(t => t.name).join(', ');
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!currentModel) return;
|
||||
const data = {};
|
||||
['title', 'description', 'author', 'license', 'category'].forEach(f => {
|
||||
const el = document.getElementById('edit-' + f);
|
||||
if (el) data[f] = el.value || null;
|
||||
});
|
||||
const tagsEl = document.getElementById('edit-tags');
|
||||
if (tagsEl) {
|
||||
data.tag_names = tagsEl.value.split(',').map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await apiPut('/models/' + currentModel.id, data);
|
||||
currentModel = updated;
|
||||
renderMeta(updated);
|
||||
document.getElementById('edit-modal').classList.add('hidden');
|
||||
showToast('Modelo actualizado', 'success');
|
||||
} catch (e) {
|
||||
showToast('Error al guardar: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function initViewer(model) {
|
||||
const container = document.getElementById('viewer');
|
||||
const loadingEl = document.getElementById('viewer-loading');
|
||||
const statusEl = document.getElementById('viewer-status');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const isLight = document.documentElement.classList.contains('light-mode');
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(isLight ? 0xf8fafc : 0x0f172a);
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
||||
camera.position.set(0, 0, 50);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.localClippingEnabled = true;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 1.0;
|
||||
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.5);
|
||||
scene.add(ambient);
|
||||
|
||||
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
mainLight.position.set(10, 20, 15);
|
||||
mainLight.castShadow = true;
|
||||
mainLight.shadow.mapSize.width = 1024;
|
||||
mainLight.shadow.mapSize.height = 1024;
|
||||
scene.add(mainLight);
|
||||
|
||||
const fillLight = new THREE.DirectionalLight(0x60a5fa, 0.3);
|
||||
fillLight.position.set(-10, 5, -10);
|
||||
scene.add(fillLight);
|
||||
|
||||
const rimLight = new THREE.DirectionalLight(0xf472b6, 0.2);
|
||||
rimLight.position.set(0, -10, -5);
|
||||
scene.add(rimLight);
|
||||
|
||||
window.gridHelper = new THREE.GridHelper(60, 60, isLight ? 0xcbd5e1 : 0x1e293b, isLight ? 0xcbd5e1 : 0x1e293b);
|
||||
window.gridHelper.position.y = -15;
|
||||
scene.add(window.gridHelper);
|
||||
|
||||
axesHelper = new THREE.AxesHelper(20);
|
||||
axesHelper.visible = false;
|
||||
scene.add(axesHelper);
|
||||
|
||||
const colors = [0x22d3ee, 0xf472b6, 0xa3e635, 0xfbbf24, 0xa78bfa, 0xfb923c];
|
||||
const modelFiles = (model.files || []).filter(f => f.file_type === 'stl' || f.file_type === '3mf');
|
||||
let loadedCount = 0;
|
||||
let totalFaces = 0;
|
||||
let globalBox = new THREE.Box3();
|
||||
|
||||
modelFiles.forEach((mf, idx) => {
|
||||
const loader = new THREE.STLLoader();
|
||||
const fileUrl = '/uploads/' + encodeURIComponent(mf.filename);
|
||||
|
||||
loader.load(fileUrl, (geometry) => {
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: colors[idx % colors.length],
|
||||
metalness: 0.1,
|
||||
roughness: 0.4,
|
||||
emissive: colors[idx % colors.length],
|
||||
emissiveIntensity: 0.05,
|
||||
side: THREE.DoubleSide,
|
||||
clippingPlanes: [],
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
mesh.userData.fileId = mf.id;
|
||||
mesh.userData.originalMaterial = material.clone();
|
||||
scene.add(mesh);
|
||||
viewerMeshes.push(mesh);
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
const center = new THREE.Vector3();
|
||||
geometry.boundingBox.getCenter(center);
|
||||
mesh.position.sub(center);
|
||||
|
||||
globalBox.expandByObject(mesh);
|
||||
|
||||
const size = new THREE.Vector3();
|
||||
geometry.boundingBox.getSize(size);
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
if (maxDim > 0) {
|
||||
const scale = 25 / maxDim;
|
||||
mesh.scale.setScalar(scale);
|
||||
}
|
||||
|
||||
totalFaces += geometry.attributes.position.count / 3;
|
||||
loadedCount++;
|
||||
|
||||
if (loadedCount === modelFiles.length) {
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
if (statusEl) statusEl.textContent = `${Math.round(totalFaces)} caras · ${formatSize(model.file_size)}`;
|
||||
|
||||
const boxCenter = new THREE.Vector3();
|
||||
globalBox.getCenter(boxCenter);
|
||||
controls.target.copy(boxCenter);
|
||||
camera.position.set(boxCenter.x, boxCenter.y, boxCenter.z + 50);
|
||||
controls.update();
|
||||
|
||||
const bottom = globalBox.min.y;
|
||||
window.gridHelper.position.y = bottom - 2;
|
||||
}
|
||||
}, undefined, (err) => {
|
||||
console.error('Error cargando STL:', err);
|
||||
loadedCount++;
|
||||
});
|
||||
});
|
||||
|
||||
if (modelFiles.length === 0) {
|
||||
if (loadingEl) loadingEl.innerHTML = '<p class="text-red-400">No hay archivos 3D para mostrar</p>';
|
||||
}
|
||||
|
||||
controls.addEventListener('start', () => { controls.autoRotate = false; });
|
||||
|
||||
// Raycaster for measurement
|
||||
renderer.domElement.addEventListener('pointerdown', onViewerPointerDown);
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
});
|
||||
}
|
||||
|
||||
function togglePart(fileId) {
|
||||
const mesh = viewerMeshes.find(m => m.userData.fileId === fileId);
|
||||
if (mesh) {
|
||||
mesh.visible = !mesh.visible;
|
||||
}
|
||||
}
|
||||
|
||||
function setViewMode(mode) {
|
||||
currentViewMode = mode;
|
||||
viewerMeshes.forEach(mesh => {
|
||||
mesh.material.wireframe = mode === 'wireframe';
|
||||
});
|
||||
|
||||
const btnSolid = document.getElementById('btn-solid');
|
||||
const btnWire = document.getElementById('btn-wireframe');
|
||||
if (btnSolid) {
|
||||
btnSolid.classList.toggle('bg-cyan-500/20', mode === 'solid');
|
||||
btnSolid.classList.toggle('text-cyan-400', mode === 'solid');
|
||||
}
|
||||
if (btnWire) {
|
||||
btnWire.classList.toggle('bg-cyan-500/20', mode === 'wireframe');
|
||||
btnWire.classList.toggle('text-cyan-400', mode === 'wireframe');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAxes() {
|
||||
axesVisible = !axesVisible;
|
||||
if (axesHelper) axesHelper.visible = axesVisible;
|
||||
const btn = document.getElementById('btn-axes');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', axesVisible);
|
||||
btn.classList.toggle('text-cyan-400', axesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBoundingBox() {
|
||||
bboxVisible = !bboxVisible;
|
||||
if (!bboxHelper && viewerMeshes.length > 0) {
|
||||
const box = new THREE.Box3();
|
||||
viewerMeshes.forEach(m => { if (m.visible) box.expandByObject(m); });
|
||||
bboxHelper = new THREE.Box3Helper(box, 0x06b6d4);
|
||||
scene.add(bboxHelper);
|
||||
}
|
||||
if (bboxHelper) bboxHelper.visible = bboxVisible;
|
||||
const btn = document.getElementById('btn-bbox');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', bboxVisible);
|
||||
btn.classList.toggle('text-cyan-400', bboxVisible);
|
||||
}
|
||||
}
|
||||
|
||||
function setCameraView(view) {
|
||||
if (!controls || !camera) return;
|
||||
const target = controls.target.clone();
|
||||
const dist = camera.position.distanceTo(target);
|
||||
|
||||
switch(view) {
|
||||
case 'front':
|
||||
camera.position.set(target.x, target.y, target.z + dist);
|
||||
break;
|
||||
case 'top':
|
||||
camera.position.set(target.x, target.y + dist, target.z);
|
||||
break;
|
||||
case 'side':
|
||||
camera.position.set(target.x + dist, target.y, target.z);
|
||||
break;
|
||||
case 'iso':
|
||||
camera.position.set(target.x + dist * 0.7, target.y + dist * 0.7, target.z + dist * 0.7);
|
||||
break;
|
||||
}
|
||||
camera.lookAt(target);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
// ====== MEASUREMENT TOOL ======
|
||||
function toggleMeasure() {
|
||||
measureMode = !measureMode;
|
||||
const btn = document.getElementById('btn-measure');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', measureMode);
|
||||
btn.classList.toggle('text-cyan-400', measureMode);
|
||||
}
|
||||
if (!measureMode) {
|
||||
clearMeasurement();
|
||||
} else {
|
||||
showToast('Haz clic en dos puntos del modelo para medir', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function clearMeasurement() {
|
||||
measurePoints = [];
|
||||
if (measureLine) { scene.remove(measureLine); measureLine = null; }
|
||||
if (measureLabel) { scene.remove(measureLabel); measureLabel = null; }
|
||||
}
|
||||
|
||||
function onViewerPointerDown(event) {
|
||||
if (!measureMode || !renderer || !camera) return;
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const mouse = new THREE.Vector2();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const visibleMeshes = viewerMeshes.filter(m => m.visible);
|
||||
const intersects = raycaster.intersectObjects(visibleMeshes);
|
||||
if (intersects.length === 0) return;
|
||||
|
||||
const point = intersects[0].point;
|
||||
measurePoints.push(point);
|
||||
|
||||
// Add small sphere marker
|
||||
const marker = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.3, 8, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff0000 })
|
||||
);
|
||||
marker.position.copy(point);
|
||||
scene.add(marker);
|
||||
measurePoints.push(marker); // store marker at odd indices
|
||||
|
||||
if (measurePoints.length >= 4) { // 2 points + 2 markers
|
||||
const p1 = measurePoints[0];
|
||||
const p2 = measurePoints[2];
|
||||
const dist = p1.distanceTo(p2);
|
||||
|
||||
if (measureLine) scene.remove(measureLine);
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
|
||||
measureLine = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: 2 }));
|
||||
scene.add(measureLine);
|
||||
|
||||
if (measureLabel) scene.remove(measureLabel);
|
||||
const mid = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = 256; canvas.height = 64;
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.roundRect(0, 0, 256, 64, 8);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 24px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(dist.toFixed(2) + ' mm', 128, 32);
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const spriteMat = new THREE.SpriteMaterial({ map: tex });
|
||||
measureLabel = new THREE.Sprite(spriteMat);
|
||||
measureLabel.position.copy(mid);
|
||||
measureLabel.scale.set(4, 1, 1);
|
||||
scene.add(measureLabel);
|
||||
|
||||
showToast(`Distancia: ${dist.toFixed(2)} mm`, 'success');
|
||||
measureMode = false;
|
||||
const btn = document.getElementById('btn-measure');
|
||||
if (btn) {
|
||||
btn.classList.remove('bg-cyan-500/20', 'text-cyan-400');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====== CLIPPING ======
|
||||
function toggleClip() {
|
||||
clipEnabled = !clipEnabled;
|
||||
const controlsDiv = document.getElementById('clip-controls');
|
||||
const btn = document.getElementById('btn-clip');
|
||||
if (controlsDiv) controlsDiv.classList.toggle('hidden', !clipEnabled);
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', clipEnabled);
|
||||
btn.classList.toggle('text-cyan-400', clipEnabled);
|
||||
}
|
||||
|
||||
if (clipEnabled) {
|
||||
if (!clipPlane) {
|
||||
clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
||||
}
|
||||
viewerMeshes.forEach(mesh => {
|
||||
mesh.material.clippingPlanes = [clipPlane];
|
||||
});
|
||||
const slider = document.getElementById('clip-slider');
|
||||
if (slider) clipPlane.constant = parseFloat(slider.value);
|
||||
} else {
|
||||
viewerMeshes.forEach(mesh => {
|
||||
mesh.material.clippingPlanes = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ====== OVERHANG HEATMAP ======
|
||||
function toggleOverhang() {
|
||||
overhangMode = !overhangMode;
|
||||
const btn = document.getElementById('btn-overhang');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', overhangMode);
|
||||
btn.classList.toggle('text-cyan-400', overhangMode);
|
||||
}
|
||||
|
||||
viewerMeshes.forEach(mesh => {
|
||||
const geo = mesh.geometry;
|
||||
if (!geo.attributes.position) return;
|
||||
|
||||
if (!overhangMode) {
|
||||
// Restore original
|
||||
if (mesh.userData.originalMaterial) {
|
||||
mesh.material = mesh.userData.originalMaterial.clone();
|
||||
if (clipEnabled) mesh.material.clippingPlanes = [clipPlane];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = geo.attributes.position;
|
||||
const count = pos.count;
|
||||
const colors = new Float32Array(count * 3);
|
||||
const colorAttr = new THREE.BufferAttribute(colors, 3);
|
||||
|
||||
// Compute face normals and assign per-vertex
|
||||
const up = new THREE.Vector3(0, 0, 1);
|
||||
const p1 = new THREE.Vector3(), p2 = new THREE.Vector3(), p3 = new THREE.Vector3();
|
||||
const n = new THREE.Vector3();
|
||||
|
||||
for (let i = 0; i < count; i += 3) {
|
||||
p1.fromBufferAttribute(pos, i);
|
||||
p2.fromBufferAttribute(pos, i + 1);
|
||||
p3.fromBufferAttribute(pos, i + 2);
|
||||
n.crossVectors(
|
||||
new THREE.Vector3().subVectors(p2, p1),
|
||||
new THREE.Vector3().subVectors(p3, p1)
|
||||
).normalize();
|
||||
const angle = Math.acos(Math.abs(n.dot(up))) * (180 / Math.PI);
|
||||
let r, g, b;
|
||||
if (angle > 60) { r = 1; g = 0; b = 0; }
|
||||
else if (angle > 45) { r = 1; g = 1; b = 0; }
|
||||
else { r = 0; g = 1; b = 0; }
|
||||
colors[i * 3] = r; colors[i * 3 + 1] = g; colors[i * 3 + 2] = b;
|
||||
colors[(i + 1) * 3] = r; colors[(i + 1) * 3 + 1] = g; colors[(i + 1) * 3 + 2] = b;
|
||||
colors[(i + 2) * 3] = r; colors[(i + 2) * 3 + 1] = g; colors[(i + 2) * 3 + 2] = b;
|
||||
}
|
||||
|
||||
geo.setAttribute('color', colorAttr);
|
||||
mesh.material = new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.1,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide,
|
||||
clippingPlanes: clipEnabled ? [clipPlane] : [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ====== LAYER BUILD ANIMATION ======
|
||||
function toggleLayerAnimation() {
|
||||
layerAnimating = !layerAnimating;
|
||||
const btn = document.getElementById('btn-layers');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', layerAnimating);
|
||||
btn.classList.toggle('text-cyan-400', layerAnimating);
|
||||
}
|
||||
|
||||
if (layerAnimating) {
|
||||
if (!clipPlane) {
|
||||
clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
||||
}
|
||||
viewerMeshes.forEach(mesh => {
|
||||
mesh.material.clippingPlanes = [clipPlane];
|
||||
});
|
||||
const box = new THREE.Box3();
|
||||
viewerMeshes.forEach(m => { if (m.visible) box.expandByObject(m); });
|
||||
const minY = box.min.y;
|
||||
const maxY = box.max.y;
|
||||
let current = minY;
|
||||
const step = (maxY - minY) / 100;
|
||||
|
||||
if (layerAnimationId) cancelAnimationFrame(layerAnimationId);
|
||||
function stepAnim() {
|
||||
if (!layerAnimating) return;
|
||||
current += step;
|
||||
if (current > maxY) current = minY;
|
||||
clipPlane.constant = current;
|
||||
layerAnimationId = requestAnimationFrame(stepAnim);
|
||||
}
|
||||
stepAnim();
|
||||
showToast('Animacion de capas iniciada', 'success');
|
||||
} else {
|
||||
if (layerAnimationId) cancelAnimationFrame(layerAnimationId);
|
||||
if (!clipEnabled) {
|
||||
viewerMeshes.forEach(mesh => {
|
||||
mesh.material.clippingPlanes = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====== VALIDATION ======
|
||||
async function runValidation() {
|
||||
const container = document.getElementById('validation-result');
|
||||
if (!container) return;
|
||||
container.classList.remove('hidden');
|
||||
container.innerHTML = '<p class="text-slate-400">Analizando malla...</p>';
|
||||
try {
|
||||
const data = await apiGet(`/models/${getModelId()}/validate`);
|
||||
container.innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div><span class="text-slate-500">Watertight:</span> <span class="${data.is_watertight ? 'text-green-400' : 'text-red-400'}">${data.is_watertight ? 'Si' : 'No'}</span></div>
|
||||
<div><span class="text-slate-500">Volumen:</span> <span class="text-slate-200">${data.volume_cm3} cm³</span></div>
|
||||
<div><span class="text-slate-500">Area:</span> <span class="text-slate-200">${data.surface_area_cm2} cm²</span></div>
|
||||
<div><span class="text-slate-500">Caras:</span> <span class="text-slate-200">${data.face_count}</span></div>
|
||||
<div><span class="text-slate-500">Vertices:</span> <span class="text-slate-200">${data.vertex_count}</span></div>
|
||||
<div><span class="text-slate-500">Euler:</span> <span class="text-slate-200">${data.euler_number}</span></div>
|
||||
<div><span class="text-slate-500">Agujeros estimados:</span> <span class="text-slate-200">${data.estimated_holes}</span></div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
container.innerHTML = `<p class="text-red-400">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ====== ESTIMATION ======
|
||||
async function runEstimation() {
|
||||
const container = document.getElementById('estimation-result');
|
||||
if (!container) return;
|
||||
container.classList.remove('hidden');
|
||||
container.innerHTML = '<p class="text-slate-400">Calculando...</p>';
|
||||
try {
|
||||
const data = await apiGet(`/models/${getModelId()}/estimate`);
|
||||
container.innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div><span class="text-slate-500">Volumen:</span> <span class="text-slate-200">${data.volume_cm3} cm³</span></div>
|
||||
<div><span class="text-slate-500">Peso:</span> <span class="text-slate-200">${data.grams} g</span></div>
|
||||
<div><span class="text-slate-500">Costo:</span> <span class="text-cyan-400 font-bold">$${data.cost}</span></div>
|
||||
<div><span class="text-slate-500">Tiempo:</span> <span class="text-slate-200">${data.estimated_time}</span></div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
container.innerHTML = `<p class="text-red-400">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ====== COMPARISON ======
|
||||
async function openCompareModal() {
|
||||
const modal = document.getElementById('compare-modal');
|
||||
const list = document.getElementById('compare-list');
|
||||
if (!modal || !list) return;
|
||||
modal.classList.remove('hidden');
|
||||
list.innerHTML = '<p class="text-sm text-slate-500">Cargando...</p>';
|
||||
try {
|
||||
const models = await apiGet('/models/?limit=100');
|
||||
const currentId = getModelId();
|
||||
const others = models.filter(m => m.id !== currentId);
|
||||
if (others.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-slate-500">No hay otros modelos para comparar.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = others.map(m => `
|
||||
<div class="flex items-center gap-3 p-2.5 rounded-xl bg-slate-900/50 border border-white/5 hover:border-cyan-500/30 cursor-pointer transition-all" onclick="compareWith(${m.id})">
|
||||
<img src="/api/models/${m.id}/thumbnail" class="w-10 h-10 rounded-lg object-cover bg-slate-800" onerror="this.style.display='none'">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">${m.title}</p>
|
||||
<p class="text-xs text-slate-500">${m.faces ?? '?'} caras · ${m.author || 'Anonimo'}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
list.innerHTML = '<p class="text-sm text-red-400">Error cargando modelos.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeCompareModal() {
|
||||
const modal = document.getElementById('compare-modal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
function compareWith(otherId) {
|
||||
const currentId = getModelId();
|
||||
window.open(`/model/${otherId}?compare=${currentId}`, '_blank');
|
||||
closeCompareModal();
|
||||
}
|
||||
39
static/js/theme.js
Normal file
39
static/js/theme.js
Normal file
@@ -0,0 +1,39 @@
|
||||
(function() {
|
||||
const STORAGE_KEY = 'stl-repo-theme';
|
||||
|
||||
function getTheme() {
|
||||
return localStorage.getItem(STORAGE_KEY) || 'dark';
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
applyTheme(theme);
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const html = document.documentElement;
|
||||
if (theme === 'light') {
|
||||
html.classList.add('light-mode');
|
||||
} else {
|
||||
html.classList.remove('light-mode');
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('themechange', { detail: theme }));
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = getTheme();
|
||||
setTheme(current === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.getTheme = getTheme;
|
||||
window.setTheme = setTheme;
|
||||
window.toggleTheme = toggleTheme;
|
||||
|
||||
// Apply on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
applyTheme(getTheme());
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.addEventListener('click', toggleTheme);
|
||||
});
|
||||
})();
|
||||
407
static/js/upload.js
Normal file
407
static/js/upload.js
Normal file
@@ -0,0 +1,407 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
269
static/upload.html
Normal file
269
static/upload.html
Normal file
@@ -0,0 +1,269 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Subir Modelo - STL Repository</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: { 850: '#172033', 950: '#020617' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<nav class="glass sticky top-0 z-50 border-b border-white/5">
|
||||
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-3 group">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center text-xl shadow-lg group-hover:scale-110 transition-transform">
|
||||
🖨️
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">STL Repository</h1>
|
||||
<p class="text-xs text-slate-400 -mt-0.5">Modelos 3D para imprimir</p>
|
||||
</div>
|
||||
</a>
|
||||
<button id="theme-toggle" class="p-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-slate-400 hover:text-yellow-400 transition-colors mr-2" title="Cambiar tema">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
</button>
|
||||
<a href="/" class="px-5 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-sm font-medium transition-colors">
|
||||
Volver a Galeria
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="max-w-3xl mx-auto px-6 py-10">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold mb-2">Subir nuevo modelo</h2>
|
||||
<p class="text-slate-400">Selecciona uno o mas archivos STL/3MF, importa desde URL o sube un ZIP.</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
<button id="tab-upload" class="flex-1 px-4 py-3 rounded-xl bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 font-medium text-sm transition-all">
|
||||
📁 Archivos
|
||||
</button>
|
||||
<button id="tab-url" class="flex-1 px-4 py-3 rounded-xl bg-slate-800 border border-white/10 text-slate-400 font-medium text-sm transition-all hover:bg-slate-700">
|
||||
🌐 URL
|
||||
</button>
|
||||
<button id="tab-zip" class="flex-1 px-4 py-3 rounded-xl bg-slate-800 border border-white/10 text-slate-400 font-medium text-sm transition-all hover:bg-slate-700">
|
||||
🗜️ ZIP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Panel: Upload -->
|
||||
<div id="panel-upload">
|
||||
<!-- 3D Files Drop Zone -->
|
||||
<div id="drop-zone" class="border-2 border-dashed border-slate-700 rounded-2xl p-10 text-center cursor-pointer transition-all hover:border-cyan-500/50 hover:bg-slate-900/40 mb-4 group">
|
||||
<div class="w-16 h-16 rounded-2xl bg-slate-900 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform group-hover:bg-cyan-500/10">
|
||||
<svg class="w-8 h-8 text-slate-400 group-hover:text-cyan-400 transition-colors" 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"></path></svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold mb-1">Archivos 3D (STL / 3MF)</h3>
|
||||
<p class="text-sm text-slate-500 mb-2">Arrastra uno o mas archivos aqui</p>
|
||||
<p id="file-name" class="text-cyan-400 font-medium text-sm min-h-[20px]"></p>
|
||||
<input type="file" id="file-input" accept=".stl,.3mf" multiple class="hidden">
|
||||
</div>
|
||||
|
||||
<!-- Parts list -->
|
||||
<div id="parts-list" class="space-y-2 mb-6"></div>
|
||||
|
||||
<!-- Images Drop Zone -->
|
||||
<div id="images-drop-zone" class="border-2 border-dashed border-slate-700 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-fuchsia-500/50 hover:bg-slate-900/40 mb-8 group">
|
||||
<div class="w-14 h-14 rounded-2xl bg-slate-900 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform group-hover:bg-fuchsia-500/10">
|
||||
<svg class="w-7 h-7 text-slate-400 group-hover:text-fuchsia-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold mb-1">Imagenes de referencia</h3>
|
||||
<p class="text-xs text-slate-500 mb-2">Opcional - JPG o PNG</p>
|
||||
<p id="images-name" class="text-fuchsia-400 font-medium text-sm min-h-[20px]"></p>
|
||||
<input type="file" id="images-input" accept=".jpg,.jpeg,.png" multiple class="hidden">
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form id="upload-form" class="glass rounded-2xl p-8 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Titulo <span class="text-cyan-500">*</span></label>
|
||||
<input type="text" id="title" required placeholder="Ej: Soporte para telefono" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Descripcion</label>
|
||||
<textarea id="description" rows="3" placeholder="Describe el modelo, materiales recomendados, etc." class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600 resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Autor</label>
|
||||
<input type="text" id="author" placeholder="Tu nombre o alias" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Licencia</label>
|
||||
<select id="license-select" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all cursor-pointer text-slate-300 mb-2">
|
||||
<option value="">Seleccionar licencia...</option>
|
||||
<option value="CC0 1.0 Universal">CC0 - Dominio Publico</option>
|
||||
<option value="CC-BY 4.0">CC-BY 4.0 - Atribucion</option>
|
||||
<option value="CC-BY-SA 4.0">CC-BY-SA 4.0 - Compartir Igual</option>
|
||||
<option value="CC-BY-NC 4.0">CC-BY-NC 4.0 - No Comercial</option>
|
||||
<option value="CC-BY-NC-SA 4.0">CC-BY-NC-SA 4.0 - NC + SA</option>
|
||||
<option value="CC-BY-ND 4.0">CC-BY-ND 4.0 - Sin Derivadas</option>
|
||||
<option value="GPL-3.0">GPL-3.0</option>
|
||||
<option value="MIT">MIT</option>
|
||||
<option value="custom">Otra (personalizada)</option>
|
||||
</select>
|
||||
<input type="text" id="license" placeholder="Ej: Mi licencia personal" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600 hidden">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Categoria</label>
|
||||
<select id="category" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all cursor-pointer text-slate-300">
|
||||
<option value="">Sin categoria</option>
|
||||
<option value="Arte">Arte</option>
|
||||
<option value="Herramientas">Herramientas</option>
|
||||
<option value="Juguetes">Juguetes</option>
|
||||
<option value="Piezas">Piezas</option>
|
||||
<option value="Decoracion">Decoracion</option>
|
||||
<option value="Otros">Otros</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Tags</label>
|
||||
<input type="text" id="tags" placeholder="robot, articulado, util" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
<div id="tags-suggestions" class="absolute left-0 right-0 top-full mt-1 glass rounded-xl border border-white/10 overflow-hidden z-20 hidden"></div>
|
||||
<p class="text-xs text-slate-500 mt-1">Separados por comas. Escribe para ver sugerencias.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<button type="submit" id="submit-btn" class="w-full md:w-auto px-8 py-3.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold shadow-lg shadow-cyan-500/20 transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
<svg id="btn-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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></svg>
|
||||
<span id="btn-text">Subir Modelo</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Panel: URL -->
|
||||
<div id="panel-url" class="hidden">
|
||||
<form id="url-form" class="glass rounded-2xl p-8 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">URL del archivo <span class="text-cyan-500">*</span></label>
|
||||
<input type="url" id="url-input" required placeholder="https://ejemplo.com/modelo.stl" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
<p class="text-xs text-slate-500 mt-1">Soporta archivos .stl y .3mf directos. Maximo 50MB.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Titulo <span class="text-cyan-500">*</span></label>
|
||||
<input type="text" id="url-title" required placeholder="Ej: Soporte para telefono" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Descripcion</label>
|
||||
<textarea id="url-description" rows="3" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600 resize-none"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Autor</label>
|
||||
<input type="text" id="url-author" placeholder="Tu nombre o alias" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Licencia</label>
|
||||
<input type="text" id="url-license" placeholder="Ej: CC-BY-SA 4.0" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Categoria</label>
|
||||
<select id="url-category" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all cursor-pointer text-slate-300">
|
||||
<option value="">Sin categoria</option>
|
||||
<option value="Arte">Arte</option>
|
||||
<option value="Herramientas">Herramientas</option>
|
||||
<option value="Juguetes">Juguetes</option>
|
||||
<option value="Piezas">Piezas</option>
|
||||
<option value="Decoracion">Decoracion</option>
|
||||
<option value="Otros">Otros</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Tags</label>
|
||||
<input type="text" id="url-tags" placeholder="robot, articulado, util" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<button type="submit" class="w-full md:w-auto px-8 py-3.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold shadow-lg shadow-cyan-500/20 transition-all hover:scale-[1.02] flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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></svg>
|
||||
Importar desde URL
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Panel: ZIP -->
|
||||
<div id="panel-zip" class="hidden">
|
||||
<div id="zip-drop-zone" class="border-2 border-dashed border-slate-700 rounded-2xl p-10 text-center cursor-pointer transition-all hover:border-cyan-500/50 hover:bg-slate-900/40 mb-6 group">
|
||||
<div class="w-16 h-16 rounded-2xl bg-slate-900 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform group-hover:bg-cyan-500/10">
|
||||
<svg class="w-8 h-8 text-slate-400 group-hover:text-cyan-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path></svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold mb-1">Archivo ZIP</h3>
|
||||
<p class="text-sm text-slate-500 mb-2">Arrastra un ZIP con archivos STL/3MF</p>
|
||||
<p id="zip-name" class="text-cyan-400 font-medium text-sm min-h-[20px]"></p>
|
||||
<input type="file" id="zip-input" accept=".zip" class="hidden">
|
||||
</div>
|
||||
|
||||
<form id="zip-form" class="glass rounded-2xl p-8 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Descripcion (aplicada a todos)</label>
|
||||
<textarea id="zip-description" rows="2" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600 resize-none"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Autor</label>
|
||||
<input type="text" id="zip-author" placeholder="Tu nombre o alias" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Licencia</label>
|
||||
<input type="text" id="zip-license" placeholder="Ej: CC-BY-SA 4.0" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Categoria</label>
|
||||
<select id="zip-category" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all cursor-pointer text-slate-300">
|
||||
<option value="">Sin categoria</option>
|
||||
<option value="Arte">Arte</option>
|
||||
<option value="Herramientas">Herramientas</option>
|
||||
<option value="Juguetes">Juguetes</option>
|
||||
<option value="Piezas">Piezas</option>
|
||||
<option value="Decoracion">Decoracion</option>
|
||||
<option value="Otros">Otros</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Tags (aplicados a todos)</label>
|
||||
<input type="text" id="zip-tags" placeholder="robot, articulado, util" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<button type="submit" class="w-full md:w-auto px-8 py-3.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold shadow-lg shadow-cyan-500/20 transition-all hover:scale-[1.02] flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path></svg>
|
||||
Procesar ZIP
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/upload.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user