23dfactory / frontend /viewer.html
LogicalTrue
feat: connect Supabase Auth client-side for registration and login
e8acfad
Raw
History Blame Contribute Delete
108 kB
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>23DFactory - Visualizador Móvil</title>
<!-- Import Google's model-viewer component -->
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js"></script>
<!-- Cropper.js for Interactive Image Cropping -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script>
<!-- Supabase JS Client -->
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
<style>
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: #121214;
color: #ffffff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
}
header {
padding: 10px 16px;
background-color: rgba(18, 18, 20, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
z-index: 1000;
min-height: 48px;
box-sizing: border-box;
}
.logo {
font-weight: 800;
font-size: 1.1rem;
letter-spacing: -0.5px;
background: linear-gradient(135deg, #a78bfa, #c084fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.btn-close {
background: none;
border: none;
color: #a1a1aa;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
}
/* Container for viewer */
#viewer-container {
width: 100%;
height: 100%;
position: relative;
display: none;
}
model-viewer {
width: 100%;
height: 100%;
background-color: #121214;
}
/* Custom AR button styling */
#ar-button {
background-color: #7c3aed;
color: white;
border-radius: 8px;
border: none;
position: absolute;
bottom: 24px;
right: 24px;
padding: 14px 20px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4);
display: flex;
align-items: center;
gap: 8px;
z-index: 99;
}
#ar-prompt {
position: absolute;
left: 50%;
bottom: 100px;
transform: translateX(-50%);
background-color: rgba(18, 18, 20, 0.85);
color: #ffffff;
padding: 8px 16px;
border-radius: 20px;
font-size: 0.85rem;
display: none;
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Loading state styling */
#loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #121214;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 5;
transition: opacity 0.5s;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.05);
border-top: 3px solid #7c3aed;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
.spinner-small {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-top: 2px solid #7c3aed;
border-radius: 50%;
animation: spin 1s linear infinite;
display: inline-block;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Gallery Selector Page */
#gallery-container {
flex: 1;
padding: 16px 20px 20px 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
box-sizing: border-box;
}
#creator-container {
flex: 1;
padding: 16px 20px 20px 20px !important;
overflow-y: auto;
box-sizing: border-box;
}
.gallery-title {
font-size: 1.35rem;
font-weight: 700;
margin-bottom: 4px;
background: linear-gradient(135deg, #ffffff, #a1a1aa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.gallery-desc {
font-size: 0.85rem;
color: #a1a1aa;
margin-bottom: 16px;
line-height: 1.4;
}
.gallery-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.model-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
transition: border-color 0.2s;
}
.model-card:active {
border-color: #7c3aed;
background: rgba(124, 58, 237, 0.05);
}
.model-info {
display: flex;
align-items: center;
gap: 12px;
}
.model-icon {
background: rgba(124, 58, 237, 0.1);
color: #c084fc;
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
}
.model-name {
font-weight: 600;
font-size: 0.9rem;
color: #ffffff;
margin-bottom: 4px;
}
.model-date {
font-size: 0.75rem;
color: #a1a1aa;
}
.btn-view {
background: #7c3aed;
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.btn-back {
position: absolute;
top: 12px;
left: 16px;
background: rgba(255, 255, 255, 0.08);
border: none;
color: #ffffff;
padding: 6px 12px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
z-index: 99;
cursor: pointer;
display: none;
}
</style>
</head>
<body>
<header id="app-header">
<span class="logo">23DFactory</span>
<div style="display: flex; gap: 8px; align-items: center;">
<!-- Mobile Background Task Indicator Banner -->
<div id="mobBgTaskBanner" style="display: none; align-items: center; gap: 6px; background: rgba(124, 58, 237, 0.2); border: 1px solid rgba(167, 139, 250, 0.5); backdrop-filter: blur(8px); padding: 4px 10px; border-radius: 16px; font-size: 0.75rem; color: #ffffff; box-shadow: 0 0 10px rgba(124, 58, 237, 0.3);">
<div class="spinner-small" style="width: 11px; height: 11px; border-width: 2px; margin: 0;"></div>
<span id="mobBgTaskText" style="font-weight: 600;">Generando... 0%</span>
</div>
<div id="mobUserCredits" style="display: none; position: relative;">
<button id="btnMobUserMenu" style="background: linear-gradient(135deg, rgba(124, 58, 237, 0.4), rgba(167, 139, 250, 0.2)); border: 1.5px solid rgba(167, 139, 250, 0.6); color: #ffffff; padding: 0; border-radius: 50%; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 0 10px rgba(124, 58, 237, 0.3); overflow: hidden; position: relative;" title="Menú de usuario">
<img id="mobUserAvatarImg" src="" alt="Avatar" style="width: 100%; height: 100%; object-fit: cover; display: none;">
<span id="mobUserAvatarInitial" style="font-size: 0.9rem; font-weight: 800; color: #ffffff; text-transform: uppercase;">P</span>
</button>
<div id="mobUserDropdown" style="display: none; position: absolute; top: calc(100% + 8px); right: 0; background: #161622; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 12px; padding: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.6); z-index: 1000; min-width: 210px; width: max-content;">
<div style="padding: 6px 10px; margin-bottom: 4px; font-size: 0.85rem; font-weight: 700; color: #ffffff; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid rgba(255,255,255,0.08); white-space: nowrap;">
👤 <span id="mobUsername">-</span>
</div>
<div style="padding: 6px 10px; margin-bottom: 6px; font-size: 0.8rem; font-weight: 700; color: #ffeb3b; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid rgba(255,255,255,0.08); white-space: nowrap;">
🪙 <span id="mobCredits">0</span> Créditos
</div>
<button id="btnMobProfile" style="width: 100%; text-align: left; background: transparent; border: none; color: #ffffff; padding: 8px 10px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap;">
👤 <span>Mi perfil</span>
</button>
<button id="btnMobPrivacy" style="width: 100%; text-align: left; background: transparent; border: none; color: #ffffff; padding: 8px 10px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap;">
🛡️ <span>Privacidad y Seguridad</span>
</button>
<div style="height: 1px; background: rgba(255,255,255,0.08); margin: 4px 0;"></div>
<button id="btnMobLogout" style="width: 100%; text-align: left; background: transparent; border: none; color: #ef4444; padding: 8px 10px; border-radius: 8px; font-size: 0.8rem; font-weight: 700; cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap;">
🚪 <span>Salir</span>
</button>
</div>
</div>
</div>
</header>
<!-- Mobile Navigation Tab Bar -->
<nav id="mobNavigation" style="display: none; background: rgba(20, 20, 28, 0.95); border-bottom: 1px solid rgba(255, 255, 255, 0.08); padding: 8px 12px; gap: 8px; justify-content: center; position: sticky; top: 0; z-index: 900; backdrop-filter: blur(10px);">
<button id="navTab3D" class="mob-nav-btn" style="flex: 1; background: transparent; border: 1px solid rgba(255, 255, 255, 0.1); color: #a1a1aa; padding: 8px 6px; border-radius: 10px; font-weight: 700; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 4px; transition: all 0.2s;">
📦 Generar 3D
</button>
<button id="navTab2D" class="mob-nav-btn" style="flex: 1; background: transparent; border: 1px solid rgba(255, 255, 255, 0.1); color: #a1a1aa; padding: 8px 6px; border-radius: 10px; font-weight: 700; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 4px; transition: all 0.2s;">
🖼️ Generar 2D
</button>
<button id="navTabGallery" class="mob-nav-btn active" style="flex: 1; background: #7c3aed; border: 1px solid #7c3aed; color: #ffffff; padding: 8px 6px; border-radius: 10px; font-weight: 700; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 4px; transition: all 0.2s; box-shadow: 0 0 10px rgba(124, 58, 237, 0.4);">
📖 Biblioteca
</button>
</nav>
<button id="btnBackToGallery" class="btn-back">⬅ Volver</button>
<!-- Mobile Login / Register Modal/Container (shown when unauthenticated) -->
<div id="mobLoginContainer" style="display: none; flex-direction: column; max-width: 400px; margin: 20px auto; width: 100%; box-sizing: border-box; padding: 20px;">
<div style="background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 16px; padding: 24px; text-align: left; box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
<div style="display: flex; background: rgba(0, 0, 0, 0.3); border-radius: 10px; padding: 3px; border: 1px solid rgba(255, 255, 255, 0.1); margin-bottom: 20px;">
<button type="button" id="tabMobLogin" style="flex: 1; background: #7c3aed; border: none; color: #fff; padding: 10px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: all 0.2s;">Iniciar Sesión</button>
<button type="button" id="tabMobRegister" style="flex: 1; background: transparent; border: none; color: #a1a1aa; padding: 10px; border-radius: 8px; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.2s;">Registrarse</button>
</div>
<!-- Login Form -->
<form id="mobLoginForm" style="display:flex; flex-direction:column; gap:14px;">
<h3 style="margin-top:0; color:#fff; font-size:1.2rem; margin-bottom:4px; font-weight:700; font-family:'Outfit', sans-serif;">¡Bienvenido de nuevo!</h3>
<p style="font-size:0.8rem; color:#a1a1aa; margin-bottom:12px; line-height:1.4;">Ingresa tus credenciales para acceder a tu biblioteca y crear modelos 3D.</p>
<div>
<label style="font-size:0.8rem; color:#a1a1aa; display:block; margin-bottom:6px; font-weight:600;">Usuario</label>
<input type="text" id="mobUser" required autocomplete="username" style="width:100%; box-sizing:border-box; padding:12px; background:#1e1e24; border:1px solid rgba(255,255,255,0.1); border-radius:8px; color:#fff; outline:none; font-family:inherit; font-size:0.9rem;">
</div>
<div>
<label style="font-size:0.8rem; color:#a1a1aa; display:block; margin-bottom:6px; font-weight:600;">Contraseña</label>
<input type="password" id="mobPass" required autocomplete="current-password" style="width:100%; box-sizing:border-box; padding:12px; background:#1e1e24; border:1px solid rgba(255,255,255,0.1); border-radius:8px; color:#fff; outline:none; font-family:inherit; font-size:0.9rem;">
</div>
<div id="mobError" style="color:#ff5252; font-size:0.8rem; display:none; background:rgba(255, 82, 82, 0.1); border:1px solid rgba(255, 82, 82, 0.2); padding:10px; border-radius:8px;"></div>
<button type="submit" id="btnMobLogin" style="background:#7c3aed; color:#fff; border:none; padding:14px; border-radius:10px; font-weight:700; font-size:0.95rem; cursor:pointer; margin-top:6px; box-shadow:0 4px 12px rgba(124,58,237,0.3);">
Iniciar Sesión
</button>
</form>
<!-- Register Form -->
<form id="mobRegisterForm" style="display:none; flex-direction:column; gap:14px;">
<h3 style="margin-top:0; color:#fff; font-size:1.2rem; margin-bottom:4px; font-weight:700; font-family:'Outfit', sans-serif;">Crear Nueva Cuenta</h3>
<p style="font-size:0.8rem; color:#a1a1aa; margin-bottom:12px; line-height:1.4;">Regístrate gratis para comenzar a generar imágenes y modelos 3D.</p>
<div>
<label style="font-size:0.8rem; color:#a1a1aa; display:block; margin-bottom:6px; font-weight:600;">Usuario</label>
<input type="text" id="mobRegUser" required autocomplete="username" placeholder="Crea tu usuario" style="width:100%; box-sizing:border-box; padding:12px; background:#1e1e24; border:1px solid rgba(255,255,255,0.1); border-radius:8px; color:#fff; outline:none; font-family:inherit; font-size:0.9rem;">
</div>
<div>
<label style="font-size:0.8rem; color:#a1a1aa; display:block; margin-bottom:6px; font-weight:600;">Contraseña</label>
<input type="password" id="mobRegPass" required autocomplete="new-password" placeholder="Mínimo 4 caracteres" style="width:100%; box-sizing:border-box; padding:12px; background:#1e1e24; border:1px solid rgba(255,255,255,0.1); border-radius:8px; color:#fff; outline:none; font-family:inherit; font-size:0.9rem;">
</div>
<div>
<label style="font-size:0.8rem; color:#a1a1aa; display:block; margin-bottom:6px; font-weight:600;">Confirmar Contraseña</label>
<input type="password" id="mobRegPassConfirm" required autocomplete="new-password" placeholder="Repite tu contraseña" style="width:100%; box-sizing:border-box; padding:12px; background:#1e1e24; border:1px solid rgba(255,255,255,0.1); border-radius:8px; color:#fff; outline:none; font-family:inherit; font-size:0.9rem;">
</div>
<div id="mobRegError" style="color:#ff5252; font-size:0.8rem; display:none; background:rgba(255, 82, 82, 0.1); border:1px solid rgba(255, 82, 82, 0.2); padding:10px; border-radius:8px;"></div>
<button type="submit" id="btnMobRegister" style="background:#7c3aed; color:#fff; border:none; padding:14px; border-radius:10px; font-weight:700; font-size:0.95rem; cursor:pointer; margin-top:6px; box-shadow:0 4px 12px rgba(124,58,237,0.3);">
Registrarse
</button>
</form>
</div>
</div>
<!-- 1. Gallery selector (shown if no model query param) -->
<div id="gallery-container" style="display: none;">
<div id="mobGalleryHeader">
<h2 class="gallery-title" style="margin-top: 0;">Tu Biblioteca 3D y 2D</h2>
<p class="gallery-desc">Tus imágenes generadas y modelos 3D listos para visualizar o proyectar en AR.</p>
</div>
<!-- Library Filter Tabs -->
<div id="mobFilterTabs" style="display: flex; gap: 8px; margin-bottom: 16px; background: rgba(255,255,255,0.03); padding: 4px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.06);">
<button id="mobFilterAll" class="mob-filter-btn active" style="flex: 1; background: #7c3aed; border: none; color: #fff; padding: 8px; border-radius: 8px; font-size: 0.8rem; font-weight: 700; cursor: pointer;">Todos</button>
<button id="mobFilter2D" class="mob-filter-btn" style="flex: 1; background: transparent; border: none; color: #a1a1aa; padding: 8px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer;">Imágenes 2D</button>
<button id="mobFilter3D" class="mob-filter-btn" style="flex: 1; background: transparent; border: none; color: #a1a1aa; padding: 8px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer;">Modelos 3D</button>
</div>
<div class="gallery-grid" id="gallery-grid">
<div style="text-align: center; padding: 40px; color: #a1a1aa; font-size: 0.9rem;">
<div class="spinner" style="margin: 0 auto 16px auto;"></div>
Cargando tu galería...
</div>
</div>
</div>
<!-- 3. Mobile Creator Container (hidden by default) -->
<div id="creator-container" style="display: none; flex-direction: column; max-width: 600px; margin: 0 auto; width: 100%;">
<div style="margin-bottom: 16px;">
<h2 id="creatorSectionTitle" style="font-family: 'Outfit', sans-serif; font-size: 1.4rem; font-weight: 700; margin: 0;">Crear Modelo 3D</h2>
</div>
<!-- Creator Tabs -->
<div id="creatorTabHeader" style="display: flex; background: rgba(255,255,255,0.05); border-radius: 10px; padding: 4px; margin-bottom: 20px;">
<button id="tabUpload" style="flex: 1; background: #27272a; border: none; color: #fff; padding: 10px; border-radius: 8px; font-size: 0.85rem; font-weight: 600; cursor: pointer;">Subir Foto</button>
<button id="tabGallerySelect" style="flex: 1; background: transparent; border: none; color: #a1a1aa; padding: 10px; border-radius: 8px; font-size: 0.85rem; font-weight: 600; cursor: pointer;">Elegir de Galería 2D</button>
</div>
<!-- Creator Contents -->
<div id="creator-contents" style="background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 20px;">
<!-- Upload Tab Panel -->
<div id="panelUpload" style="display: flex; flex-direction: column; gap: 16px; text-align: center;">
<div id="dropzone" style="border: 2px dashed rgba(124,58,237,0.3); border-radius: 12px; padding: 35px 20px; background: rgba(124,58,237,0.02); cursor: pointer; text-align: center;">
<div style="font-size: 2.8rem; margin-bottom: 12px;">🖼️</div>
<div style="font-size: 0.95rem; font-weight: 600; color: #fff; margin-bottom: 4px;">Sube una foto o imagen desde tu celular</div>
<div style="font-size: 0.8rem; color: #a1a1aa;">Formato JPG o PNG con fondo claro/limpio</div>
<input type="file" id="fileInputMobile" accept="image/*" style="display: none;">
</div>
<div id="imagePreviewContainer" style="display: none; position: relative;">
<img id="imagePreviewMobile" style="max-width: 100%; max-height: 250px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); margin: 0 auto; display: block;">
<button id="btnRemoveImageMobile" style="position: absolute; top: 8px; right: 8px; background: rgba(239,68,68,0.9); color: white; border: none; border-radius: 50%; width: 28px; height: 28px; font-weight: bold; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 0.8rem;"></button>
<button type="button" id="btnOpenCropMobile" style="margin-top: 10px; width: 100%; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 0.85rem; padding: 10px; background: rgba(124, 58, 237, 0.2); border: 1px solid rgba(124, 58, 237, 0.5); color: #a78bfa; border-radius: 8px; cursor: pointer; font-weight: 600;">
✂️ Enmarcar / Recortar Sujeto
</button>
</div>
</div>
<!-- Gallery Selector Tab Panel -->
<div id="panelGallerySelect" style="display: none; flex-direction: column; gap: 12px; text-align: left;">
<div style="font-size: 0.85rem; color: #a1a1aa; margin-bottom: 4px;">Selecciona una de tus imágenes 2D almacenadas:</div>
<div id="mobGalleryImageGrid" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; max-height: 280px; overflow-y: auto; padding-right: 4px;">
<div style="color: #a1a1aa; font-size: 0.8rem; grid-column: span 2; text-align: center; padding: 20px;">Cargando imágenes...</div>
</div>
</div>
<!-- Prompt Tab Panel (Generar 2D) -->
<div id="panelPrompt" style="display: none; flex-direction: column; gap: 14px; text-align: left;">
<div>
<label style="font-size:0.75rem; color:#a1a1aa; display:block; margin-bottom:6px; font-weight:600;">Describe tu idea (Inglés preferido)</label>
<textarea id="promptInputMobile" rows="3" placeholder="Ej. A cute small red dragon, cartoon style, highly detailed, white background..." style="width:100%; box-sizing:border-box; padding:12px; background:#1e1e24; border:1px solid rgba(255,255,255,0.1); border-radius:8px; color:#fff; outline:none; font-family:inherit; font-size:0.9rem; resize: none;"></textarea>
</div>
<button type="button" id="btnGen2DMobile" style="background:#7c3aed; color:#fff; border:none; padding:12px; border-radius:8px; font-weight:700; font-size:0.9rem; cursor:pointer; border-radius:10px;">
Generar Imagen 2D (Cuesta 1 crédito)
</button>
<div id="aiImageContainer" style="display: none; flex-direction: column; gap: 10px; align-items: center; margin-top: 10px;">
<img id="aiImageMobile" style="max-width: 100%; max-height: 250px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); margin: 0 auto; display: block;">
</div>
</div>
</div>
<!-- Action Button -->
<div style="margin-top: 20px;">
<button id="btnCreate3DMobile" style="width: 100%; background: #10b981; color: #fff; border: none; padding: 14px; border-radius: 10px; font-weight: 700; font-size: 1rem; cursor: pointer; display: none; align-items: center; justify-content: center; gap: 8px; box-shadow: 0 4px 12px rgba(10,185,129,0.25);">
📦 Crear Modelo 3D (Cuesta 5 créditos)
</button>
</div>
</div>
<!-- 2. Interactive model viewer (shown when active) -->
<div id="viewer-container" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100vh; z-index: 9999; background: #121214;">
<!-- Viewer Header Control Overlay -->
<div id="viewerHeaderBar" style="position: absolute; top: 0; left: 0; right: 0; z-index: 10000; display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: rgba(18, 18, 20, 0.9); border-bottom: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px);">
<span class="logo" style="font-size: 1.1rem; user-select: none;">23DFactory</span>
<div style="display: flex; gap: 8px; align-items: center;">
<button id="btnDownloadViewerFBX" style="background: linear-gradient(135deg, #10b981, #059669); border: none; color: #ffffff; padding: 8px 14px; border-radius: 20px; font-size: 0.8rem; font-weight: 700; cursor: pointer; box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); display: flex; align-items: center; justify-content: center; z-index: 10001;">
Descargar FBX
</button>
<button id="btnBackToGallery" onclick="exitViewerToGallery()" style="background: linear-gradient(135deg, #ef4444, #dc2626); border: none; color: #ffffff; padding: 8px 14px; border-radius: 20px; font-size: 0.8rem; font-weight: 700; cursor: pointer; box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); display: flex; align-items: center; justify-content: center; z-index: 10001;">
Volver
</button>
</div>
</div>
<div id="loading-overlay" style="z-index: 9999;">
<div class="spinner"></div>
<div id="loading-text" style="font-size: 0.9rem; color: #a1a1aa;">Cargando archivo 3D...</div>
</div>
<model-viewer
id="viewer"
src=""
camera-controls
touch-action="pan-y"
shadow-intensity="1"
auto-rotate
interaction-prompt="auto"
style="width: 100%; height: 100%; padding-top: 50px; box-sizing: border-box;">
</model-viewer>
</div>
<!-- 2. Interactive 2D Image viewer (shown when active) -->
<div id="image-viewer-container" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100vh; z-index: 9999; background: #121214; flex-direction: column; justify-content: space-between;">
<!-- Image Viewer Header Control Overlay -->
<div id="imageViewerHeaderBar" style="position: absolute; top: 0; left: 0; right: 0; z-index: 10000; display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: rgba(18, 18, 20, 0.9); border-bottom: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px);">
<span class="logo" style="font-size: 1.1rem; user-select: none;">23DFactory</span>
<div style="display: flex; gap: 8px; align-items: center;">
<button id="btnCreate3DFrom2DViewer" style="background: linear-gradient(135deg, #10b981, #059669); border: none; color: #ffffff; padding: 8px 14px; border-radius: 20px; font-size: 0.8rem; font-weight: 700; cursor: pointer; box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); display: flex; align-items: center; justify-content: center; z-index: 10001;">
Crear Modelo 3D
</button>
<button id="btnBackFrom2DViewer" onclick="close2DImageViewer()" style="background: linear-gradient(135deg, #ef4444, #dc2626); border: none; color: #ffffff; padding: 8px 14px; border-radius: 20px; font-size: 0.8rem; font-weight: 700; cursor: pointer; box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); display: flex; align-items: center; justify-content: center; z-index: 10001;">
Volver
</button>
</div>
</div>
<!-- Centered 2D Image View -->
<div style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; padding: 70px 16px 20px 16px; box-sizing: border-box;">
<img id="full2DImageView" src="" style="max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.1);">
</div>
</div>
<!-- Interactive Crop Modal Overlay -->
<div id="cropModalOverlay" style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999999; background: rgba(10, 10, 15, 0.95); backdrop-filter: blur(12px); flex-direction: column; align-items: center; justify-content: space-between; padding: 16px; box-sizing: border-box;">
<!-- Modal Header -->
<div style="width: 100%; max-width: 600px; display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,0.1);">
<div style="font-weight: 700; font-size: 1rem; color: #fff; display: flex; align-items: center; gap: 8px;">
✂️ Enmarcar / Recortar Sujeto
</div>
<button id="btnCancelCrop" style="background: rgba(255,255,255,0.1); border: none; color: #fff; width: 32px; height: 32px; border-radius: 50%; cursor: pointer; font-weight: bold; font-size: 1rem; display: flex; align-items: center; justify-content: center;"></button>
</div>
<!-- Crop Canvas Viewport Container -->
<div style="width: 100%; max-width: 600px; height: calc(100vh - 180px); max-height: 480px; display: flex; align-items: center; justify-content: center; overflow: hidden; position: relative; margin: 12px 0; background: #000; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1);">
<img id="cropImageTarget" src="" style="max-width: 100%; max-height: 100%;">
</div>
<!-- Modal Controls Footer -->
<div style="width: 100%; max-width: 600px; display: flex; flex-direction: column; gap: 10px;">
<div style="display: flex; gap: 8px; justify-content: center; flex-wrap: wrap;">
<button type="button" id="btnCropAspectFree" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: #fff; padding: 6px 14px; border-radius: 16px; font-size: 0.78rem; cursor: pointer; font-weight: 600;">Libre</button>
<button type="button" id="btnCropAspectSquare" style="background: rgba(124,58,237,0.3); border: 1px solid #7c3aed; color: #fff; padding: 6px 14px; border-radius: 16px; font-size: 0.78rem; cursor: pointer; font-weight: 600;">1:1 Cuadrado (Recomendado)</button>
<button type="button" id="btnCropReset" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: #fff; padding: 6px 14px; border-radius: 16px; font-size: 0.78rem; cursor: pointer; font-weight: 600;">Reiniciar</button>
</div>
<button type="button" id="btnApplyCrop" style="width: 100%; background: linear-gradient(135deg, #10b981, #059669); color: #fff; border: none; padding: 12px; border-radius: 10px; font-weight: 700; font-size: 0.95rem; cursor: pointer; box-shadow: 0 4px 12px rgba(16,185,129,0.3); display: flex; align-items: center; justify-content: center; gap: 6px;">
✔ Confirmar Recorte
</button>
</div>
</div>
<!-- User Profile Modal (Mobile) -->
<div id="mobProfileModal" style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999999; background: rgba(10, 10, 15, 0.95); backdrop-filter: blur(12px); flex-direction: column; align-items: center; justify-content: center; padding: 16px; box-sizing: border-box;">
<div style="width: 100%; max-width: 420px; background: #161622; border: 1px solid rgba(255,255,255,0.12); border-radius: 16px; padding: 20px; box-shadow: 0 15px 35px rgba(0,0,0,0.8);">
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 16px;">
<div style="font-size: 1.1rem; font-weight: 700; color: #fff; display: flex; align-items: center; gap: 6px;">
👤 <span>Mi Perfil</span>
</div>
<button type="button" id="btnCloseMobProfile" style="background: rgba(255,255,255,0.1); border: none; color: #fff; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; font-weight: bold; font-size: 0.9rem;"></button>
</div>
<form id="mobProfileForm" style="display: flex; flex-direction: column; gap: 14px;">
<div style="display: flex; align-items: center; gap: 12px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.1); padding: 12px; border-radius: 12px;">
<div style="position: relative; width: 56px; height: 56px; border-radius: 50%; overflow: hidden; border: 2px solid #7c3aed; flex-shrink: 0; background: #000;">
<img id="mobProfileAvatarPreview" src="" alt="Avatar" style="width: 100%; height: 100%; object-fit: cover;">
</div>
<div style="flex: 1; display: flex; flex-direction: column; gap: 4px;">
<label for="mobProfileAvatarInput" style="font-size: 0.75rem; padding: 6px 12px; background: rgba(124, 58, 237, 0.2); border: 1px solid #7c3aed; color: #a78bfa; border-radius: 8px; font-weight: 600; cursor: pointer; display: inline-block; width: fit-content;">
📷 Cambiar foto
</label>
<input type="file" id="mobProfileAvatarInput" accept="image/*" style="display: none;">
<span style="font-size: 0.7rem; color: #a1a1aa;">Formato JPG o PNG</span>
</div>
</div>
<div>
<label style="font-size: 0.8rem; color: #a1a1aa; display: block; margin-bottom: 6px; font-weight: 600;">Apodo / Nickname</label>
<input type="text" id="mobProfileNickInput" required style="width: 100%; box-sizing: border-box; padding: 10px 12px; background: #1e1e24; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; font-size: 0.9rem;">
</div>
<div>
<label style="font-size: 0.8rem; color: #a1a1aa; display: block; margin-bottom: 6px; font-weight: 600;">Nombre Completo (Opcional)</label>
<input type="text" id="mobProfileFullNameInput" style="width: 100%; box-sizing: border-box; padding: 10px 12px; background: #1e1e24; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; font-size: 0.9rem;">
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end; margin-top: 6px;">
<button type="button" id="btnCancelMobProfile" style="background: rgba(255,255,255,0.1); border: none; color: #fff; padding: 10px 16px; border-radius: 8px; font-weight: 600; font-size: 0.85rem; cursor: pointer;">Cancelar</button>
<button type="submit" style="background: #7c3aed; border: none; color: #fff; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.85rem; cursor: pointer;">Guardar</button>
</div>
</form>
</div>
<!-- Privacy & Security Modal (Mobile) -->
<div id="mobPrivacyModal" style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999999; background: rgba(10, 10, 15, 0.95); backdrop-filter: blur(12px); flex-direction: column; align-items: center; justify-content: center; padding: 16px; box-sizing: border-box;">
<div style="width: 100%; max-width: 420px; background: #161622; border: 1px solid rgba(255,255,255,0.12); border-radius: 16px; padding: 20px; box-shadow: 0 15px 35px rgba(0,0,0,0.8);">
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 16px;">
<div style="font-size: 1.1rem; font-weight: 700; color: #ffffff; display: flex; align-items: center; gap: 6px;">
🛡️ <span>Privacidad y Seguridad</span>
</div>
<button type="button" id="btnCloseMobPrivacy" style="background: rgba(255,255,255,0.1); border: none; color: #fff; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; font-weight: bold; font-size: 0.9rem;"></button>
</div>
<form id="mobPrivacyForm" style="display: flex; flex-direction: column; gap: 12px;">
<div>
<label style="font-size: 0.8rem; color: #a1a1aa; display: block; margin-bottom: 6px; font-weight: 600;">Correo Electrónico</label>
<input type="email" id="mobPrivacyEmailInput" style="width: 100%; box-sizing: border-box; padding: 10px 12px; background: #1e1e24; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; font-size: 0.9rem;">
</div>
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px; margin-top: 4px;">
<div style="font-size: 0.85rem; font-weight: 700; color: #fff; margin-bottom: 8px;">Cambiar Contraseña</div>
<div style="margin-bottom: 8px;">
<label style="font-size: 0.75rem; color: #a1a1aa; display: block; margin-bottom: 4px;">Contraseña Actual</label>
<input type="password" id="mobPrivacyCurrentPasswordInput" style="width: 100%; box-sizing: border-box; padding: 8px 12px; background: #1e1e24; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; font-size: 0.85rem;">
</div>
<div style="margin-bottom: 8px;">
<label style="font-size: 0.75rem; color: #a1a1aa; display: block; margin-bottom: 4px;">Nueva Contraseña</label>
<input type="password" id="mobPrivacyNewPasswordInput" style="width: 100%; box-sizing: border-box; padding: 8px 12px; background: #1e1e24; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; font-size: 0.85rem;">
</div>
<div>
<label style="font-size: 0.75rem; color: #a1a1aa; display: block; margin-bottom: 4px;">Repetir Nueva Contraseña</label>
<input type="password" id="mobPrivacyConfirmPasswordInput" style="width: 100%; box-sizing: border-box; padding: 8px 12px; background: #1e1e24; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; font-size: 0.85rem;">
</div>
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px;">
<button type="button" id="btnCancelMobPrivacy" style="background: rgba(255,255,255,0.1); border: none; color: #fff; padding: 10px 16px; border-radius: 8px; font-weight: 600; font-size: 0.85rem; cursor: pointer;">Cancelar</button>
<button type="submit" style="background: #4caf50; border: none; color: #fff; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.85rem; cursor: pointer;">Guardar</button>
</div>
</form>
</div>
</div>
<script>
const urlParams = new URLSearchParams(window.location.search);
const modelUrl = urlParams.get('model');
let currentViewerUrl = '';
let current2DViewerUrl = '';
const galleryContainer = document.getElementById('gallery-container');
const viewerContainer = document.getElementById('viewer-container');
const viewer = document.getElementById('viewer');
const overlay = document.getElementById('loading-overlay');
const loadingText = document.getElementById('loading-text');
const btnBack = document.getElementById('btnBackToGallery');
const appHeader = document.getElementById('app-header');
const galleryGrid = document.getElementById('gallery-grid');
function exitViewerToGallery() {
console.log("[Viewer] Exiting 3D viewer mode");
// If page was opened with ?model=... query string, clean redirect to /viewer.html
if (window.location.search && window.location.search.includes('model=')) {
window.location.href = window.location.pathname;
return;
}
if (viewerContainer) viewerContainer.style.display = 'none';
if (viewer) viewer.src = '';
if (appHeader) appHeader.style.display = 'flex';
const mobNavigation = document.getElementById('mobNavigation');
const mobGalleryHeader = document.getElementById('mobGalleryHeader');
const mobFilterTabs = document.getElementById('mobFilterTabs');
const navTabGalleryEl = document.getElementById('navTabGallery');
const navTab3DEl = document.getElementById('navTab3D');
const navTab2DEl = document.getElementById('navTab2D');
const creatorContainerEl = document.getElementById('creator-container');
if (creatorContainerEl) creatorContainerEl.style.display = 'none';
if (mobNavigation) mobNavigation.style.display = 'flex';
if (mobGalleryHeader) mobGalleryHeader.style.display = 'block';
if (mobFilterTabs) mobFilterTabs.style.display = 'flex';
if (galleryContainer) galleryContainer.style.display = 'flex';
[navTab3DEl, navTab2DEl, navTabGalleryEl].forEach(btn => {
if (!btn) return;
btn.style.background = 'transparent';
btn.style.borderColor = 'rgba(255, 255, 255, 0.1)';
btn.style.color = '#a1a1aa';
btn.style.boxShadow = 'none';
});
if (navTabGalleryEl) {
navTabGalleryEl.style.background = '#7c3aed';
navTabGalleryEl.style.borderColor = '#7c3aed';
navTabGalleryEl.style.color = '#ffffff';
navTabGalleryEl.style.boxShadow = '0 0 10px rgba(124, 58, 237, 0.4)';
}
fetchGallery();
}
// Back button to list models
if (btnBack) {
btnBack.onclick = function(e) {
if (e) e.stopPropagation();
exitViewerToGallery();
};
}
const btnDownloadViewerFBX = document.getElementById('btnDownloadViewerFBX');
if (btnDownloadViewerFBX) {
btnDownloadViewerFBX.onclick = function(e) {
if (e) e.stopPropagation();
if (!currentViewerUrl) return;
let fbxUrl = currentViewerUrl;
if (fbxUrl.endsWith('.glb')) {
fbxUrl = fbxUrl.replace('.glb', '.fbx');
}
const a = document.createElement('a');
a.href = fbxUrl;
a.download = fbxUrl.split('/').pop();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
}
function open2DImageViewer(imageUrl) {
current2DViewerUrl = imageUrl;
const fullImg = document.getElementById('full2DImageView');
if (fullImg) fullImg.src = imageUrl;
if (appHeader) appHeader.style.display = 'none';
const mobNavigation = document.getElementById('mobNavigation');
if (mobNavigation) mobNavigation.style.display = 'none';
if (galleryContainer) galleryContainer.style.display = 'none';
const imgViewer = document.getElementById('image-viewer-container');
if (imgViewer) imgViewer.style.display = 'flex';
}
function close2DImageViewer() {
const imgViewer = document.getElementById('image-viewer-container');
if (imgViewer) imgViewer.style.display = 'none';
if (appHeader) appHeader.style.display = 'flex';
const mobNavigation = document.getElementById('mobNavigation');
if (mobNavigation) mobNavigation.style.display = 'flex';
if (galleryContainer) galleryContainer.style.display = 'flex';
const navTabGalleryEl = document.getElementById('navTabGallery');
if (navTabGalleryEl) {
const navTab3DEl = document.getElementById('navTab3D');
const navTab2DEl = document.getElementById('navTab2D');
[navTab3DEl, navTab2DEl, navTabGalleryEl].forEach(btn => {
if (!btn) return;
btn.style.background = 'transparent';
btn.style.borderColor = 'rgba(255, 255, 255, 0.1)';
btn.style.color = '#a1a1aa';
btn.style.boxShadow = 'none';
});
navTabGalleryEl.style.background = '#7c3aed';
navTabGalleryEl.style.borderColor = '#7c3aed';
navTabGalleryEl.style.color = '#ffffff';
navTabGalleryEl.style.boxShadow = '0 0 10px rgba(124, 58, 237, 0.4)';
}
}
const btnCreate3DFrom2DViewer = document.getElementById('btnCreate3DFrom2DViewer');
if (btnCreate3DFrom2DViewer) {
btnCreate3DFrom2DViewer.onclick = function() {
if (!current2DViewerUrl) return;
const imgViewer = document.getElementById('image-viewer-container');
if (imgViewer) imgViewer.style.display = 'none';
if (appHeader) appHeader.style.display = 'flex';
const mobNavigation = document.getElementById('mobNavigation');
if (mobNavigation) mobNavigation.style.display = 'flex';
selectImageFor3D(current2DViewerUrl);
const navTab3DEl = document.getElementById('navTab3D');
if (navTab3DEl) {
const navTab2DEl = document.getElementById('navTab2D');
const navTabGalleryEl = document.getElementById('navTabGallery');
[navTab3DEl, navTab2DEl, navTabGalleryEl].forEach(btn => {
if (!btn) return;
btn.style.background = 'transparent';
btn.style.borderColor = 'rgba(255, 255, 255, 0.1)';
btn.style.color = '#a1a1aa';
btn.style.boxShadow = 'none';
});
navTab3DEl.style.background = '#7c3aed';
navTab3DEl.style.borderColor = '#7c3aed';
navTab3DEl.style.color = '#ffffff';
navTab3DEl.style.boxShadow = '0 0 10px rgba(124, 58, 237, 0.4)';
}
};
}
// Load 3D model in viewer
function loadModelInViewer(url) {
currentViewerUrl = url;
const mobNavigation = document.getElementById('mobNavigation');
if (mobNavigation) mobNavigation.style.display = 'none';
if (galleryContainer) galleryContainer.style.display = 'none';
const creatorContainerEl = document.getElementById('creator-container');
if (creatorContainerEl) creatorContainerEl.style.display = 'none';
if (appHeader) appHeader.style.display = 'none';
viewerContainer.style.display = 'block';
overlay.style.display = 'flex';
overlay.style.opacity = '1';
loadingText.textContent = "Cargando archivo 3D...";
// Assign src to model viewer
viewer.src = url;
// Safety timeout: if model is cached or 'load' event fired early, hide overlay after 2 seconds max
setTimeout(() => {
if (overlay.style.display !== 'none') {
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; }, 300);
}
}, 2000);
}
viewer.addEventListener('load', () => {
console.log('[Viewer] Model loaded');
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; }, 300);
});
viewer.addEventListener('error', (e) => {
console.error('Error loading model:', e);
loadingText.innerHTML = '<span style="color: #ef4444;">Error al cargar el archivo 3D.</span>';
setTimeout(() => {
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; }, 300);
}, 3000);
});
let galleryItems = [];
let currentGalleryFilter = 'all';
// Library Filter Tabs
const mobFilterAll = document.getElementById('mobFilterAll');
const mobFilter2D = document.getElementById('mobFilter2D');
const mobFilter3D = document.getElementById('mobFilter3D');
function setGalleryFilterActive(activeBtn, filter) {
currentGalleryFilter = filter;
[mobFilterAll, mobFilter2D, mobFilter3D].forEach(btn => {
if (!btn) return;
if (btn === activeBtn) {
btn.style.background = '#7c3aed';
btn.style.color = '#fff';
btn.style.fontWeight = '700';
} else {
btn.style.background = 'transparent';
btn.style.color = '#a1a1aa';
btn.style.fontWeight = '600';
}
});
renderGallery();
}
if (mobFilterAll) mobFilterAll.addEventListener('click', () => setGalleryFilterActive(mobFilterAll, 'all'));
if (mobFilter2D) mobFilter2D.addEventListener('click', () => setGalleryFilterActive(mobFilter2D, 'image'));
if (mobFilter3D) mobFilter3D.addEventListener('click', () => setGalleryFilterActive(mobFilter3D, 'model'));
function renderMobileLoginForm(errorMsg = '') {
const mobNavigation = document.getElementById('mobNavigation');
const mobUserCredits = document.getElementById('mobUserCredits');
const mobGalleryHeader = document.getElementById('mobGalleryHeader');
const mobFilterTabs = document.getElementById('mobFilterTabs');
const mobLoginContainer = document.getElementById('mobLoginContainer');
const galleryContainerEl = document.getElementById('gallery-container');
const creatorContainerEl = document.getElementById('creator-container');
const mobError = document.getElementById('mobError');
if (mobNavigation) mobNavigation.style.display = 'none';
if (mobUserCredits) mobUserCredits.style.display = 'none';
if (mobGalleryHeader) mobGalleryHeader.style.display = 'none';
if (mobFilterTabs) mobFilterTabs.style.display = 'none';
if (creatorContainerEl) creatorContainerEl.style.display = 'none';
if (galleryContainerEl) galleryContainerEl.style.display = 'none';
if (mobLoginContainer) mobLoginContainer.style.display = 'flex';
if (mobError) {
if (errorMsg) {
mobError.textContent = errorMsg;
mobError.style.display = 'block';
} else {
mobError.style.display = 'none';
}
}
}
// Mobile Auth Tab Switcher & Register Form Handlers
const tabMobLogin = document.getElementById('tabMobLogin');
const tabMobRegister = document.getElementById('tabMobRegister');
const mobLoginForm = document.getElementById('mobLoginForm');
const mobRegisterForm = document.getElementById('mobRegisterForm');
if (tabMobLogin && tabMobRegister) {
tabMobLogin.addEventListener('click', () => {
tabMobLogin.style.background = '#7c3aed';
tabMobLogin.style.color = '#fff';
tabMobLogin.style.fontWeight = '700';
tabMobRegister.style.background = 'transparent';
tabMobRegister.style.color = '#a1a1aa';
tabMobRegister.style.fontWeight = '600';
if (mobLoginForm) mobLoginForm.style.display = 'flex';
if (mobRegisterForm) mobRegisterForm.style.display = 'none';
});
tabMobRegister.addEventListener('click', () => {
tabMobRegister.style.background = '#7c3aed';
tabMobRegister.style.color = '#fff';
tabMobRegister.style.fontWeight = '700';
tabMobLogin.style.background = 'transparent';
tabMobLogin.style.color = '#a1a1aa';
tabMobLogin.style.fontWeight = '600';
if (mobRegisterForm) mobRegisterForm.style.display = 'flex';
if (mobLoginForm) mobLoginForm.style.display = 'none';
});
}
if (mobRegisterForm) {
mobRegisterForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('mobRegUser').value.trim();
const password = document.getElementById('mobRegPass').value;
const confirmPass = document.getElementById('mobRegPassConfirm').value;
const regErrorEl = document.getElementById('mobRegError');
const regBtn = document.getElementById('btnMobRegister');
if (regErrorEl) regErrorEl.style.display = 'none';
if (password !== confirmPass) {
if (regErrorEl) {
regErrorEl.textContent = 'Las contraseñas no coinciden.';
regErrorEl.style.display = 'block';
}
return;
}
if (password.length < 4) {
if (regErrorEl) {
regErrorEl.textContent = 'La contraseña debe tener al menos 4 caracteres.';
regErrorEl.style.display = 'block';
}
return;
}
if (regBtn) {
regBtn.disabled = true;
regBtn.textContent = 'Registrando...';
}
try {
const regRes = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (regRes.ok) {
// Registration success, proceed to auto-login
const loginRes = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (loginRes.ok) {
const mobLoginContainer = document.getElementById('mobLoginContainer');
if (mobLoginContainer) mobLoginContainer.style.display = 'none';
if (galleryContainer) galleryContainer.style.display = 'flex';
const mobNavigation = document.getElementById('mobNavigation');
const mobGalleryHeader = document.getElementById('mobGalleryHeader');
const mobFilterTabs = document.getElementById('mobFilterTabs');
if (mobNavigation) mobNavigation.style.display = 'flex';
if (mobGalleryHeader) mobGalleryHeader.style.display = 'block';
if (mobFilterTabs) mobFilterTabs.style.display = 'flex';
if (regBtn) {
regBtn.disabled = false;
regBtn.textContent = 'Registrarse';
}
fetchUserInfo();
fetchGallery();
} else {
if (tabMobLogin) tabMobLogin.click();
}
} else {
const errData = await regRes.json().catch(() => ({}));
if (regBtn) {
regBtn.disabled = false;
regBtn.textContent = 'Registrarse';
}
if (regErrorEl) {
regErrorEl.textContent = errData.error || 'No se pudo completar el registro';
regErrorEl.style.display = 'block';
}
}
} catch (err) {
if (regBtn) {
regBtn.disabled = false;
regBtn.textContent = 'Registrarse';
}
if (regErrorEl) {
regErrorEl.textContent = 'Error de conexión al intentar registrarse';
regErrorEl.style.display = 'block';
}
}
});
}
if (mobLoginForm) {
mobLoginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('mobUser').value.trim();
const password = document.getElementById('mobPass').value;
const errorEl = document.getElementById('mobError');
const btn = document.getElementById('btnMobLogin');
if (errorEl) errorEl.style.display = 'none';
if (btn) {
btn.disabled = true;
btn.textContent = 'Verificando...';
}
try {
const loginRes = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (loginRes.ok) {
const mobLoginContainer = document.getElementById('mobLoginContainer');
if (mobLoginContainer) mobLoginContainer.style.display = 'none';
if (galleryContainer) galleryContainer.style.display = 'flex';
const mobNavigation = document.getElementById('mobNavigation');
const mobGalleryHeader = document.getElementById('mobGalleryHeader');
const mobFilterTabs = document.getElementById('mobFilterTabs');
if (mobNavigation) mobNavigation.style.display = 'flex';
if (mobGalleryHeader) mobGalleryHeader.style.display = 'block';
if (mobFilterTabs) mobFilterTabs.style.display = 'flex';
if (btn) {
btn.disabled = false;
btn.textContent = 'Iniciar Sesión';
}
fetchUserInfo();
fetchGallery();
} else {
const errData = await loginRes.json().catch(() => ({}));
if (btn) {
btn.disabled = false;
btn.textContent = 'Iniciar Sesión';
}
if (errorEl) {
errorEl.textContent = errData.error || 'Usuario o contraseña incorrectos';
errorEl.style.display = 'block';
}
}
} catch (e) {
if (btn) {
btn.disabled = false;
btn.textContent = 'Iniciar Sesión';
}
if (errorEl) {
errorEl.textContent = 'Error de conexión con el servidor';
errorEl.style.display = 'block';
}
}
});
}
async function fetchGallery() {
try {
const response = await fetch('/api/gallery');
if (response.status === 401) {
renderMobileLoginForm();
return;
}
const data = await response.json();
const mobNavigation = document.getElementById('mobNavigation');
const mobGalleryHeader = document.getElementById('mobGalleryHeader');
const mobFilterTabs = document.getElementById('mobFilterTabs');
const mobLoginContainer = document.getElementById('mobLoginContainer');
const creatorContainerEl = document.getElementById('creator-container');
if (mobLoginContainer) mobLoginContainer.style.display = 'none';
if (mobNavigation) mobNavigation.style.display = 'flex';
// Only show galleryContainer if Creator panel is not currently active
if (!creatorContainerEl || creatorContainerEl.style.display === 'none') {
if (galleryContainer) galleryContainer.style.display = 'flex';
if (mobGalleryHeader) mobGalleryHeader.style.display = 'block';
if (mobFilterTabs) mobFilterTabs.style.display = 'flex';
}
galleryItems = data.items || [];
renderGallery();
renderMobGalleryImageGrid();
checkActiveUserJob();
} catch (err) {
console.error(err);
if (galleryGrid) {
galleryGrid.innerHTML = `<div style="color: #ef4444; text-align: center; padding: 24px;">Error al conectar con la galería: ${err.message}</div>`;
}
}
}
let isPollingActiveJob = false;
async function checkActiveUserJob() {
if (isPollingActiveJob) return;
try {
const res = await fetch('/api/user-active-job');
if (!res.ok) return;
const data = await res.json();
if (data.has_active && data.job_id) {
isPollingActiveJob = true;
resumeActiveJobPolling(data.job_id, data.type);
}
} catch (e) {
console.warn('[Viewer] Error checking active job:', e);
}
}
function resumeActiveJobPolling(jobId, jobType) {
const mobBgTaskBanner = document.getElementById('mobBgTaskBanner');
const mobBgTaskText = document.getElementById('mobBgTaskText');
const typeLabel = jobType === '2d' ? '2D' : '3D';
if (mobBgTaskBanner && mobBgTaskText) {
mobBgTaskText.textContent = `Generando ${typeLabel}... 0%`;
mobBgTaskBanner.style.display = 'flex';
}
const pollInterval = setInterval(async () => {
try {
const statusRes = await fetch(`/api/job-status?job_id=${jobId}`);
if (!statusRes.ok) return;
const job = await statusRes.json();
if (job.status === 'processing' || job.status === 'pending') {
const progress = job.progress || 10;
if (mobBgTaskBanner && mobBgTaskText) {
mobBgTaskText.textContent = `Generando ${typeLabel}... ${progress}%`;
mobBgTaskBanner.style.display = 'flex';
}
} else if (job.status === 'completed') {
clearInterval(pollInterval);
isPollingActiveJob = false;
if (mobBgTaskBanner) mobBgTaskBanner.style.display = 'none';
try { fetchGallery(); } catch (e) {}
try { fetchUserInfo(); } catch (e) {}
} else if (job.status === 'failed') {
clearInterval(pollInterval);
isPollingActiveJob = false;
if (mobBgTaskBanner) mobBgTaskBanner.style.display = 'none';
}
} catch (e) {
console.warn('[Viewer] Active job poll warning:', e);
}
}, 2500);
}
function renderGallery() {
const filtered = galleryItems.filter(item => {
if (currentGalleryFilter === 'all') return true;
return item.type === currentGalleryFilter;
});
if (filtered.length === 0) {
galleryGrid.innerHTML = `
<div style="text-align: center; padding: 40px; color: #a1a1aa; border: 1px dashed rgba(255,255,255,0.08); border-radius: 12px;">
<div style="font-size: 2.5rem; margin-bottom: 12px;">📁</div>
<div style="font-size: 0.9rem; font-weight: 600; color: #ffffff; margin-bottom: 4px;">No hay elementos guardados</div>
<div style="font-size: 0.8rem; color: #a1a1aa;">Genera una imagen 2D o un modelo 3D para verlo aquí.</div>
</div>
`;
return;
}
galleryGrid.innerHTML = '';
filtered.forEach(item => {
const date = new Date(item.mtime * 1000).toLocaleString('es-ES', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
});
const card = document.createElement('div');
card.className = 'model-card';
if (item.type === 'model') {
card.innerHTML = `
<div class="model-info">
<div class="model-icon">📦</div>
<div>
<div class="model-name">${item.name.split('/').pop()}</div>
<div class="model-date">${date}</div>
</div>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<button class="btn-view" style="background:#7c3aed; color:#fff; border:none; padding:8px 12px; border-radius:8px; font-size:1.1rem; cursor:pointer; display:flex; align-items:center; justify-content:center;" title="Visualizar 3D">👁️</button>
<button class="btn-delete" style="background:rgba(239,68,68,0.15); color:#ef4444; border:1px solid rgba(239,68,68,0.3); padding:8px 10px; border-radius:8px; font-size:1rem; cursor:pointer; display:flex; align-items:center; justify-content:center;" title="Eliminar">🗑️</button>
</div>
`;
card.querySelector('.btn-view').addEventListener('click', () => loadModelInViewer(item.url));
} else {
card.innerHTML = `
<div class="model-info">
<img src="${item.url}" style="width:44px; height:44px; object-fit:cover; border-radius:8px; border:1px solid rgba(255,255,255,0.1); flex-shrink:0;">
<div>
<div class="model-name">${item.name}</div>
<div class="model-date">${date}</div>
</div>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<button class="btn-view-2d" style="background:#7c3aed; color:#fff; border:none; padding:8px 12px; border-radius:8px; font-size:1.1rem; cursor:pointer; display:flex; align-items:center; justify-content:center;" title="Visualizar Imagen 2D">👁️</button>
<button class="btn-delete" style="background:rgba(239,68,68,0.15); color:#ef4444; border:1px solid rgba(239,68,68,0.3); padding:8px 10px; border-radius:8px; font-size:1rem; cursor:pointer; display:flex; align-items:center; justify-content:center;" title="Eliminar">🗑️</button>
</div>
`;
card.querySelector('.btn-view-2d').addEventListener('click', () => open2DImageViewer(item.url));
}
const btnDel = card.querySelector('.btn-delete');
if (btnDel) {
btnDel.addEventListener('click', async () => {
if (confirm(`¿Eliminar ${item.name}?`)) {
try {
const res = await fetch(`/api/gallery-item?name=${encodeURIComponent(item.name)}&type=${item.type}`, { method: 'DELETE' });
if (res.ok) fetchGallery();
} catch (e) {
console.error(e);
}
}
});
}
galleryGrid.appendChild(card);
});
}
function renderMobGalleryImageGrid() {
const grid = document.getElementById('mobGalleryImageGrid');
if (!grid) return;
const images = galleryItems.filter(item => item.type === 'image');
if (images.length === 0) {
grid.innerHTML = `<div style="color:#a1a1aa; font-size:0.8rem; grid-column:span 2; text-align:center; padding:20px;">No tienes imágenes 2D guardadas.<br>Genera una en la sección "Generar 2D".</div>`;
return;
}
grid.innerHTML = '';
images.forEach(img => {
const itemDiv = document.createElement('div');
itemDiv.style.cssText = 'position:relative; cursor:pointer; border-radius:8px; overflow:hidden; border:2px solid transparent; aspect-ratio:1; transition:all 0.2s;';
itemDiv.innerHTML = `<img src="${img.url}" style="width:100%; height:100%; object-fit:cover;">`;
itemDiv.addEventListener('click', () => {
grid.querySelectorAll('div').forEach(d => d.style.borderColor = 'transparent');
itemDiv.style.borderColor = '#10b981';
selectImageFor3D(img.url);
});
grid.appendChild(itemDiv);
});
}
function selectImageFor3D(imageUrl) {
base64Image = imageUrl;
imagePreviewMobile.src = imageUrl;
dropzone.style.display = 'none';
imagePreviewContainer.style.display = 'block';
btnCreate3DMobile.style.display = 'flex';
// Show creator 3D view
if (navTab3D) setMobNavActive(navTab3D);
galleryContainer.style.display = 'none';
creatorContainer.style.display = 'flex';
// Select Upload tab panel
if (tabUpload) tabUpload.click();
}
// Creator script variables
let activeCreatorTab = 'upload';
let base64Image = null;
const creatorContainer = document.getElementById('creator-container');
const tabUpload = document.getElementById('tabUpload');
const tabGallerySelect = document.getElementById('tabGallerySelect');
const panelUpload = document.getElementById('panelUpload');
const panelGallerySelect = document.getElementById('panelGallerySelect');
const panelPrompt = document.getElementById('panelPrompt');
const dropzone = document.getElementById('dropzone');
const fileInputMobile = document.getElementById('fileInputMobile');
const imagePreviewContainer = document.getElementById('imagePreviewContainer');
const imagePreviewMobile = document.getElementById('imagePreviewMobile');
const btnRemoveImageMobile = document.getElementById('btnRemoveImageMobile');
const promptInputMobile = document.getElementById('promptInputMobile');
const btnGen2DMobile = document.getElementById('btnGen2DMobile');
const aiImageContainer = document.getElementById('aiImageContainer');
const aiImageMobile = document.getElementById('aiImageMobile');
const btnCreate3DFrom2DGenerated = document.getElementById('btnCreate3DFrom2DGenerated');
const btnCreate3DMobile = document.getElementById('btnCreate3DMobile');
// Navigation between creator and gallery tabs
const navTab3D = document.getElementById('navTab3D');
const navTab2D = document.getElementById('navTab2D');
const navTabGallery = document.getElementById('navTabGallery');
function setMobNavActive(activeBtn) {
[navTab3D, navTab2D, navTabGallery].forEach(btn => {
if (!btn) return;
if (btn === activeBtn) {
btn.style.background = '#7c3aed';
btn.style.borderColor = '#7c3aed';
btn.style.color = '#ffffff';
btn.style.boxShadow = '0 0 10px rgba(124, 58, 237, 0.4)';
} else {
btn.style.background = 'transparent';
btn.style.borderColor = 'rgba(255, 255, 255, 0.1)';
btn.style.color = '#a1a1aa';
btn.style.boxShadow = 'none';
}
});
}
if (navTab3D) {
navTab3D.addEventListener('click', () => {
setMobNavActive(navTab3D);
galleryContainer.style.display = 'none';
creatorContainer.style.display = 'flex';
document.getElementById('creatorSectionTitle').textContent = 'Crear Modelo 3D';
document.getElementById('creatorTabHeader').style.display = 'flex';
if (tabUpload) tabUpload.click();
});
}
if (navTab2D) {
navTab2D.addEventListener('click', () => {
setMobNavActive(navTab2D);
galleryContainer.style.display = 'none';
creatorContainer.style.display = 'flex';
document.getElementById('creatorSectionTitle').textContent = 'Generar Imagen 2D con IA';
document.getElementById('creatorTabHeader').style.display = 'none';
panelUpload.style.display = 'none';
if (panelGallerySelect) panelGallerySelect.style.display = 'none';
panelPrompt.style.display = 'flex';
btnCreate3DMobile.style.display = 'none';
});
}
if (navTabGallery) {
navTabGallery.addEventListener('click', () => {
setMobNavActive(navTabGallery);
creatorContainer.style.display = 'none';
galleryContainer.style.display = 'flex';
fetchGallery();
});
}
function resetCreatorState() {
base64Image = null;
if (fileInputMobile) fileInputMobile.value = '';
if (imagePreviewContainer) imagePreviewContainer.style.display = 'none';
if (dropzone) dropzone.style.display = 'flex';
if (promptInputMobile) promptInputMobile.value = '';
if (aiImageContainer) aiImageContainer.style.display = 'none';
if (btnCreate3DMobile) {
btnCreate3DMobile.disabled = false;
btnCreate3DMobile.style.display = 'none';
}
}
// Sub-tab switcher inside Crear Modelo 3D
if (tabUpload && tabGallerySelect) {
tabUpload.addEventListener('click', () => {
tabUpload.style.background = '#27272a';
tabUpload.style.color = '#fff';
tabGallerySelect.style.background = 'transparent';
tabGallerySelect.style.color = '#a1a1aa';
panelUpload.style.display = 'flex';
panelGallerySelect.style.display = 'none';
panelPrompt.style.display = 'none';
btnCreate3DMobile.style.display = imagePreviewContainer.style.display === 'block' ? 'flex' : 'none';
});
tabGallerySelect.addEventListener('click', () => {
tabGallerySelect.style.background = '#27272a';
tabGallerySelect.style.color = '#fff';
tabUpload.style.background = 'transparent';
tabUpload.style.color = '#a1a1aa';
panelGallerySelect.style.display = 'flex';
panelUpload.style.display = 'none';
panelPrompt.style.display = 'none';
renderMobGalleryImageGrid();
btnCreate3DMobile.style.display = base64Image ? 'flex' : 'none';
});
}
// Upload handlers
if (dropzone && fileInputMobile) {
dropzone.addEventListener('click', () => {
fileInputMobile.click();
});
fileInputMobile.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
base64Image = event.target.result;
imagePreviewMobile.src = base64Image;
dropzone.style.display = 'none';
imagePreviewContainer.style.display = 'block';
btnCreate3DMobile.style.display = 'flex';
};
reader.readAsDataURL(file);
});
}
if (btnRemoveImageMobile) {
btnRemoveImageMobile.addEventListener('click', () => {
base64Image = null;
if (fileInputMobile) fileInputMobile.value = '';
if (cameraInputMobile) cameraInputMobile.value = '';
imagePreviewContainer.style.display = 'none';
dropzone.style.display = 'flex';
btnCreate3DMobile.style.display = 'none';
});
}
// Cropper.js Integration for Mobile View
let mobileCropper = null;
const btnOpenCropMobile = document.getElementById('btnOpenCropMobile');
const cropModalOverlay = document.getElementById('cropModalOverlay');
const cropImageTarget = document.getElementById('cropImageTarget');
const btnCancelCrop = document.getElementById('btnCancelCrop');
const btnApplyCrop = document.getElementById('btnApplyCrop');
const btnCropAspectFree = document.getElementById('btnCropAspectFree');
const btnCropAspectSquare = document.getElementById('btnCropAspectSquare');
const btnCropReset = document.getElementById('btnCropReset');
function initCropperMobile(imageUrl) {
if (!cropModalOverlay || !cropImageTarget) return;
cropImageTarget.src = imageUrl;
cropModalOverlay.style.display = 'flex';
if (mobileCropper) {
mobileCropper.destroy();
mobileCropper = null;
}
setTimeout(() => {
mobileCropper = new Cropper(cropImageTarget, {
aspectRatio: 1,
viewMode: 1,
autoCropArea: 0.85,
responsive: true,
background: false
});
}, 150);
}
if (btnOpenCropMobile) {
btnOpenCropMobile.addEventListener('click', () => {
if (base64Image) {
initCropperMobile(base64Image);
}
});
}
if (btnCancelCrop) {
btnCancelCrop.addEventListener('click', () => {
if (cropModalOverlay) cropModalOverlay.style.display = 'none';
if (mobileCropper) {
mobileCropper.destroy();
mobileCropper = null;
}
});
}
if (btnCropAspectFree) {
btnCropAspectFree.addEventListener('click', () => {
if (mobileCropper) mobileCropper.setAspectRatio(NaN);
});
}
if (btnCropAspectSquare) {
btnCropAspectSquare.addEventListener('click', () => {
if (mobileCropper) mobileCropper.setAspectRatio(1);
});
}
if (btnCropReset) {
btnCropReset.addEventListener('click', () => {
if (mobileCropper) mobileCropper.reset();
});
}
if (btnApplyCrop) {
btnApplyCrop.addEventListener('click', () => {
if (!mobileCropper) return;
const croppedCanvas = mobileCropper.getCroppedCanvas({
width: 1024,
height: 1024,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
});
if (croppedCanvas) {
base64Image = croppedCanvas.toDataURL('image/png');
if (imagePreviewMobile) imagePreviewMobile.src = base64Image;
if (cropModalOverlay) cropModalOverlay.style.display = 'none';
if (mobileCropper) {
mobileCropper.destroy();
mobileCropper = null;
}
}
});
}
// 2D AI Generation
if (btnGen2DMobile) {
btnGen2DMobile.addEventListener('click', async () => {
const prompt = promptInputMobile.value.trim();
if (!prompt) {
alert('Por favor escribe una descripción para la imagen.');
return;
}
btnGen2DMobile.disabled = true;
btnGen2DMobile.textContent = 'Generando...';
creatorStatus.style.display = 'block';
creatorStatusTitle.textContent = 'Generando Imagen 2D...';
creatorStatusDesc.textContent = 'Esto puede tardar unos segundos. Por favor, espera.';
const mobBgTaskBanner = document.getElementById('mobBgTaskBanner');
const mobBgTaskText = document.getElementById('mobBgTaskText');
try {
const response = await fetch('/api/generate-2d', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, model: 'stabilityai/stable-diffusion-3.5-large', negative_prompt: '', token: '' })
});
if (response.status === 402) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Créditos insuficientes para generar la imagen (Cuesta 1 crédito).');
}
if (response.status !== 202) {
throw new Error('Error al iniciar la generación de la imagen.');
}
const initData = await response.json();
const jobId = initData.job_id;
if (mobBgTaskBanner && mobBgTaskText) {
mobBgTaskText.textContent = `Generando 2D... 0%`;
mobBgTaskBanner.style.display = 'flex';
}
const pollInterval = setInterval(async () => {
try {
const statusRes = await fetch(`/api/job-status?job_id=${jobId}`);
if (!statusRes.ok) return;
const job = await statusRes.json();
if (job.status === 'processing' || job.status === 'pending') {
const progress = job.progress || 10;
if (mobBgTaskBanner && mobBgTaskText) {
mobBgTaskText.textContent = `Generando 2D... ${progress}%`;
mobBgTaskBanner.style.display = 'flex';
}
} else if (job.status === 'completed') {
clearInterval(pollInterval);
if (mobBgTaskBanner) mobBgTaskBanner.style.display = 'none';
base64Image = job.result.image;
aiImageMobile.src = base64Image;
aiImageContainer.style.display = 'flex';
btnGen2DMobile.disabled = false;
btnGen2DMobile.textContent = 'Generar Otra Imagen (Cuesta 1 crédito)';
creatorStatus.style.display = 'none';
// Refresh gallery & credits
fetchGallery();
fetchUserInfo();
} else if (job.status === 'failed') {
clearInterval(pollInterval);
if (mobBgTaskBanner) mobBgTaskBanner.style.display = 'none';
throw new Error(job.error || 'La generación de imagen falló.');
}
} catch (err) {
clearInterval(pollInterval);
if (mobBgTaskBanner) mobBgTaskBanner.style.display = 'none';
btnGen2DMobile.disabled = false;
btnGen2DMobile.textContent = 'Generar Imagen 2D (Cuesta 1 crédito)';
creatorStatus.style.display = 'none';
alert('Error: ' + err.message);
}
}, 2000);
} catch (err) {
if (mobBgTaskBanner) mobBgTaskBanner.style.display = 'none';
btnGen2DMobile.disabled = false;
btnGen2DMobile.textContent = 'Generar Imagen 2D (Cuesta 1 crédito)';
creatorStatus.style.display = 'none';
alert('Error: ' + err.message);
}
});
}
// 3D Model Generation
if (btnCreate3DMobile) {
btnCreate3DMobile.addEventListener('click', async () => {
if (!base64Image) {
alert('Por favor, toma/sube una foto o genera una imagen con IA antes de crear el modelo 3D.');
return;
}
const mobBgTaskBanner = document.getElementById('mobBgTaskBanner');
const mobBgTaskText = document.getElementById('mobBgTaskText');
if (mobBgTaskBanner && mobBgTaskText) {
mobBgTaskText.textContent = `Generando 3D... 0%`;
mobBgTaskBanner.style.display = 'flex';
}
try {
let response = null;
let initRetries = 0;
const maxInitRetries = 3;
while (initRetries < maxInitRetries) {
try {
response = await fetch('/api/generate-3d', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
version: 'v2',
image: base64Image,
token: '',
seed: 42,
resolution: '1536',
decimation_target: 100000,
texture_size: 4096,
ss_guidance: 7.5,
ss_steps: 12,
slat_guidance: 3.0,
slat_steps: 12,
auto_optimize: false,
quad_target_faces: 60000,
remeshMethod: 'cleanup',
prompt: ''
})
});
break;
} catch (netErr) {
initRetries++;
if (initRetries >= maxInitRetries) throw netErr;
console.warn(`[Init 3D Retry ${initRetries}/${maxInitRetries}] Error de conexión, reintentando...`);
await new Promise(r => setTimeout(r, 1500));
}
}
const resJson = await response.json().catch(() => ({}));
if (response.status === 402) {
throw new Error(resJson.error || 'Créditos insuficientes para crear el modelo 3D (Cuesta 5 créditos).');
}
if (response.status !== 202) {
throw new Error(resJson.error || resJson.message || 'Error al iniciar la generación del modelo 3D.');
}
const jobId = resJson.job_id;
// Refresh credits immediately since they are charged on job initiation
fetchUserInfo();
if (mobBgTaskBanner && mobBgTaskText) {
mobBgTaskText.textContent = `Generando 3D... 0%`;
mobBgTaskBanner.style.display = 'flex';
}
let consecutiveFailures = 0;
const maxAllowedFailures = 12;
const pollInterval = setInterval(async () => {
try {
const statusRes = await fetch(`/api/job-status?job_id=${jobId}`);
if (!statusRes.ok) {
consecutiveFailures++;
if (consecutiveFailures < maxAllowedFailures) return;
throw new Error('No se pudo obtener respuesta del servidor tras varios intentos.');
}
consecutiveFailures = 0;
const job = await statusRes.json();
if (job.status === 'processing' || job.status === 'pending') {
const progress = job.progress || 10;
if (mobBgTaskBanner && mobBgTaskText) {
mobBgTaskText.textContent = `Generando 3D... ${progress}%`;
mobBgTaskBanner.style.display = 'flex';
}
} else if (job.status === 'completed') {
clearInterval(pollInterval);
btnCreate3DMobile.disabled = false;
if (mobBgTaskBanner) mobBgTaskBanner.style.display = 'none';
resetCreatorState();
try { fetchGallery(); } catch (e) {}
try { fetchUserInfo(); } catch (e) {}
const resData = job.result;
const modelUrl = resData ? (resData.gltfUrl || resData.glbUrl) : null;
if (modelUrl) {
try {
loadModelInViewer(modelUrl);
} catch (errLoad) {
console.error("Error cargando visor:", errLoad);
alert('¡Modelo 3D creado con éxito! Se ha añadido a tu biblioteca.');
}
} else {
alert('¡Modelo 3D creado con éxito! Se ha añadido a tu biblioteca.');
}
} else if (job.status === 'failed') {
clearInterval(pollInterval);
if (mobBgTaskBanner) mobBgTaskBanner.style.display = 'none';
throw new Error(job.message || job.error || 'La generación 3D falló.');
}
} catch (err) {
const errStr = (err.message || '' ) + (err.name || '');
if (errStr.includes('TypeError') || errStr.includes('Failed to fetch') || errStr.includes('NetworkError') || errStr.includes('fetch')) {
consecutiveFailures++;
if (consecutiveFailures < maxAllowedFailures) {
console.warn(`[Network Retry ${consecutiveFailures}/${maxAllowedFailures}] Reconectando a la tarea 3D...`);
return;
}
}
clearInterval(pollInterval);
if (mobBgTaskBanner) mobBgTaskBanner.style.display = 'none';
btnCreate3DMobile.disabled = false;
alert('Error al generar modelo 3D: ' + err.message);
fetchUserInfo();
}
}, 2500);
} catch (err) {
if (mobBgTaskBanner) mobBgTaskBanner.style.display = 'none';
btnCreate3DMobile.disabled = false;
alert('Error: ' + err.message);
fetchUserInfo();
}
});
}
// Fetch User Info (Username and Credits)
async function fetchUserInfo() {
try {
const response = await fetch('/api/me');
const mobUserCredits = document.getElementById('mobUserCredits');
const mobUsername = document.getElementById('mobUsername');
const mobCredits = document.getElementById('mobCredits');
const mobUserAvatarImg = document.getElementById('mobUserAvatarImg');
const mobUserAvatarInitial = document.getElementById('mobUserAvatarInitial');
if (response.ok) {
const data = await response.json();
if (data.username) {
const displayName = data.nick || data.username;
if (mobUsername) mobUsername.textContent = displayName;
if (mobCredits) mobCredits.textContent = data.credits || 0;
if (data.avatar && (data.avatar.startsWith('data:image') || data.avatar.startsWith('http') || data.avatar.startsWith('/'))) {
if (mobUserAvatarImg) {
mobUserAvatarImg.src = data.avatar;
mobUserAvatarImg.style.display = 'block';
}
if (mobUserAvatarInitial) mobUserAvatarInitial.style.display = 'none';
} else {
if (mobUserAvatarImg) mobUserAvatarImg.style.display = 'none';
if (mobUserAvatarInitial) {
mobUserAvatarInitial.textContent = displayName.charAt(0).toUpperCase();
mobUserAvatarInitial.style.display = 'block';
}
}
if (mobUserCredits) mobUserCredits.style.display = 'block';
} else {
if (mobUserCredits) mobUserCredits.style.display = 'none';
renderMobileLoginForm();
}
} else {
if (mobUserCredits) mobUserCredits.style.display = 'none';
renderMobileLoginForm();
}
} catch (err) {
console.error("Error fetching user info:", err);
}
}
// Desktop redirection force setter
const btnGoToDesktop = document.getElementById('btnGoToDesktop');
if (btnGoToDesktop) {
btnGoToDesktop.addEventListener('click', () => {
sessionStorage.setItem('forceDesktop', 'true');
sessionStorage.setItem('dismissedMobileWarning', 'true');
});
}
// User Menu Dropdown Handler
const btnMobUserMenu = document.getElementById('btnMobUserMenu');
const mobUserDropdown = document.getElementById('mobUserDropdown');
if (btnMobUserMenu && mobUserDropdown) {
btnMobUserMenu.addEventListener('click', (e) => {
e.stopPropagation();
const isVisible = mobUserDropdown.style.display === 'block';
mobUserDropdown.style.display = isVisible ? 'none' : 'block';
});
document.addEventListener('click', (e) => {
if (!btnMobUserMenu.contains(e.target) && !mobUserDropdown.contains(e.target)) {
mobUserDropdown.style.display = 'none';
}
});
}
// Mobile Profile and Privacy Modals Bindings
const btnMobProfile = document.getElementById('btnMobProfile');
const btnMobPrivacy = document.getElementById('btnMobPrivacy');
const mobProfileModal = document.getElementById('mobProfileModal');
const mobPrivacyModal = document.getElementById('mobPrivacyModal');
const btnCloseMobProfile = document.getElementById('btnCloseMobProfile');
const btnCancelMobProfile = document.getElementById('btnCancelMobProfile');
const btnCloseMobPrivacy = document.getElementById('btnCloseMobPrivacy');
const btnCancelMobPrivacy = document.getElementById('btnCancelMobPrivacy');
const mobProfileForm = document.getElementById('mobProfileForm');
const mobPrivacyForm = document.getElementById('mobPrivacyForm');
const mobProfileAvatarInput = document.getElementById('mobProfileAvatarInput');
const mobProfileAvatarPreview = document.getElementById('mobProfileAvatarPreview');
if (btnMobProfile) {
btnMobProfile.addEventListener('click', (e) => {
e.stopPropagation();
if (mobUserDropdown) mobUserDropdown.style.display = 'none';
fetch('/api/me').then(res => res.json()).then(data => {
const nickInput = document.getElementById('mobProfileNickInput');
const fullNameInput = document.getElementById('mobProfileFullNameInput');
if (nickInput) nickInput.value = data.nick || data.username || '';
if (fullNameInput) fullNameInput.value = data.full_name || '';
if (mobProfileAvatarPreview && data.avatar) mobProfileAvatarPreview.src = data.avatar;
if (mobProfileModal) mobProfileModal.style.display = 'flex';
}).catch(() => {
if (mobProfileModal) mobProfileModal.style.display = 'flex';
});
});
}
if (btnMobPrivacy) {
btnMobPrivacy.addEventListener('click', (e) => {
e.stopPropagation();
if (mobUserDropdown) mobUserDropdown.style.display = 'none';
fetch('/api/me').then(res => res.json()).then(data => {
const emailInput = document.getElementById('mobPrivacyEmailInput');
if (emailInput) emailInput.value = data.email || '';
if (mobPrivacyModal) mobPrivacyModal.style.display = 'flex';
}).catch(() => {
if (mobPrivacyModal) mobPrivacyModal.style.display = 'flex';
});
});
}
if (mobProfileAvatarInput && mobProfileAvatarPreview) {
mobProfileAvatarInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
mobProfileAvatarPreview.src = evt.target.result;
};
reader.readAsDataURL(file);
}
});
}
[btnCloseMobProfile, btnCancelMobProfile].forEach(btn => {
if (btn) btn.addEventListener('click', () => mobProfileModal.style.display = 'none');
});
[btnCloseMobPrivacy, btnCancelMobPrivacy].forEach(btn => {
if (btn) btn.addEventListener('click', () => mobPrivacyModal.style.display = 'none');
});
if (mobProfileForm) {
mobProfileForm.addEventListener('submit', async (e) => {
e.preventDefault();
const nick = document.getElementById('mobProfileNickInput')?.value.trim();
const full_name = document.getElementById('mobProfileFullNameInput')?.value.trim();
const avatar = mobProfileAvatarPreview?.src;
try {
const res = await fetch('/api/update-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nick, full_name, avatar })
});
if (res.ok) {
alert('¡Perfil actualizado con éxito!');
if (mobProfileModal) mobProfileModal.style.display = 'none';
fetchUserInfo();
} else {
alert('Error al actualizar el perfil.');
}
} catch (err) {
alert('Error de conexión al actualizar perfil.');
}
});
}
if (mobPrivacyForm) {
mobPrivacyForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('mobPrivacyEmailInput')?.value.trim();
const current_password = document.getElementById('mobPrivacyCurrentPasswordInput')?.value;
const new_password = document.getElementById('mobPrivacyNewPasswordInput')?.value;
const confirm_password = document.getElementById('mobPrivacyConfirmPasswordInput')?.value;
if (new_password && new_password !== confirm_password) {
alert('Las contraseñas no coinciden.');
return;
}
try {
const res = await fetch('/api/update-privacy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, current_password, new_password })
});
const data = await res.json();
if (res.ok && data.success) {
alert('¡Configuración de privacidad guardada!');
if (mobPrivacyModal) mobPrivacyModal.style.display = 'none';
} else {
alert(data.error || 'Error al guardar privacidad.');
}
} catch (err) {
alert('Error de conexión al guardar privacidad.');
}
});
}
// Logout handler
const btnMobLogout = document.getElementById('btnMobLogout');
if (btnMobLogout) {
btnMobLogout.addEventListener('click', async () => {
if (confirm('¿Estás seguro de que deseas cerrar sesión?')) {
try {
document.cookie = "session_user=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
document.cookie = "session_user=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure";
await fetch('/api/logout', { method: 'POST' });
if (mobUserDropdown) mobUserDropdown.style.display = 'none';
renderMobileLoginForm();
} catch (e) {
console.error("Error logging out:", e);
renderMobileLoginForm();
}
}
});
}
// Routing Logic
if (modelUrl) {
loadModelInViewer(modelUrl);
} else {
fetchGallery();
fetchUserInfo();
}
</script>
</body>
</html>