Upload 214 files
Browse files- .gitattributes +4 -0
- backend/.env +1 -0
- backend/__pycache__/main.cpython-312.pyc +0 -0
- backend/logs/app.log +0 -0
- backend/main.py +14 -2
- backend/routers/__pycache__/catalog.cpython-312.pyc +0 -0
- backend/routers/__pycache__/pages.cpython-312.pyc +0 -0
- backend/routers/__pycache__/sessions.cpython-312.pyc +0 -0
- backend/routers/catalog.py +194 -123
- backend/routers/sessions.py +93 -0
- backend/services/__pycache__/image_service.cpython-312.pyc +0 -0
- backend/services/__pycache__/texture_service.cpython-312.pyc +0 -0
- backend/services/texture_service.py +1 -1
- backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png +3 -0
- backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png +3 -0
- backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png +3 -0
- backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png +3 -0
- frontend/src/features/roomSetup/RoomSetup.tsx +122 -3
- frontend/src/features/roomSetup/roomSetupHooks.ts +15 -0
- frontend/src/features/roomVisualizer/RoomPreviewPanel.tsx +24 -5
- frontend/src/features/roomVisualizer/RoomVisualizer.tsx +109 -18
- frontend/src/features/roomVisualizer/useCatalogProducts.ts +24 -14
- frontend/src/hooks/useSessionSync.ts +65 -0
- frontend/src/store/useAppStore.ts +57 -1
- frontend/src/version.ts +1 -1
.gitattributes
CHANGED
|
@@ -149,3 +149,7 @@ backend/uploads/demo-room.jpg filter=lfs diff=lfs merge=lfs -text
|
|
| 149 |
backend/uploads/proyecto-White-Houses_edit_d4a831c5_edit_c5a95477.jpg filter=lfs diff=lfs merge=lfs -text
|
| 150 |
backend/uploads/demo-room_edit_3755debd_edit_cdb044f5_edit_cc649bbc.jpg filter=lfs diff=lfs merge=lfs -text
|
| 151 |
backend/uploads/demo-room_edit_3755debd_edit_cdb044f5.jpg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
backend/uploads/proyecto-White-Houses_edit_d4a831c5_edit_c5a95477.jpg filter=lfs diff=lfs merge=lfs -text
|
| 150 |
backend/uploads/demo-room_edit_3755debd_edit_cdb044f5_edit_cc649bbc.jpg filter=lfs diff=lfs merge=lfs -text
|
| 151 |
backend/uploads/demo-room_edit_3755debd_edit_cdb044f5.jpg filter=lfs diff=lfs merge=lfs -text
|
| 152 |
+
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png filter=lfs diff=lfs merge=lfs -text
|
| 153 |
+
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png filter=lfs diff=lfs merge=lfs -text
|
| 154 |
+
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png filter=lfs diff=lfs merge=lfs -text
|
| 155 |
+
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png filter=lfs diff=lfs merge=lfs -text
|
backend/.env
CHANGED
|
@@ -5,3 +5,4 @@ GRADIO_SPACE_URL=https://eduardo4547-hyper-reality-sam2-gpu.hf.space
|
|
| 5 |
GRADIO_CPU_FALLBACK_URL=https://eduardo4547-hyper-reality-sam2-cpu.hf.space
|
| 6 |
# Para desarrollo local:
|
| 7 |
# GRADIO_SPACE_URL=http://localhost:7860
|
|
|
|
|
|
| 5 |
GRADIO_CPU_FALLBACK_URL=https://eduardo4547-hyper-reality-sam2-cpu.hf.space
|
| 6 |
# Para desarrollo local:
|
| 7 |
# GRADIO_SPACE_URL=http://localhost:7860
|
| 8 |
+
# https://eduardo4547-hyper-reality-sam2-gpu.hf.space
|
backend/__pycache__/main.cpython-312.pyc
CHANGED
|
Binary files a/backend/__pycache__/main.cpython-312.pyc and b/backend/__pycache__/main.cpython-312.pyc differ
|
|
|
backend/logs/app.log
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/main.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import mimetypes
|
|
|
|
| 2 |
import subprocess
|
| 3 |
import threading
|
| 4 |
import time
|
|
@@ -12,7 +13,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
| 13 |
|
| 14 |
from core.config import GRADIO_SPACE_URL, logger
|
| 15 |
-
from routers import auth, catalog, media, pages, segmentation, share
|
|
|
|
| 16 |
from services.sam2_service import lifespan
|
| 17 |
|
| 18 |
mimetypes.add_type("application/javascript", ".js", strict=True)
|
|
@@ -27,7 +29,7 @@ app.add_middleware(
|
|
| 27 |
CORSMiddleware,
|
| 28 |
allow_origins=["*"],
|
| 29 |
allow_credentials=True,
|
| 30 |
-
allow_methods=["GET", "POST", "OPTIONS"],
|
| 31 |
allow_headers=["*"],
|
| 32 |
)
|
| 33 |
|
|
@@ -47,6 +49,7 @@ app.include_router(auth.router)
|
|
| 47 |
app.include_router(share.router)
|
| 48 |
app.include_router(media.router)
|
| 49 |
app.include_router(catalog.router)
|
|
|
|
| 50 |
app.include_router(segmentation.router)
|
| 51 |
|
| 52 |
# Static files
|
|
@@ -105,6 +108,15 @@ def watch_frontend_changes(interval: float = 2.0) -> None:
|
|
| 105 |
last_state = current_state
|
| 106 |
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
@app.on_event("startup")
|
| 109 |
async def startup_watch_frontend():
|
| 110 |
thread = threading.Thread(target=watch_frontend_changes, daemon=True)
|
|
|
|
| 1 |
import mimetypes
|
| 2 |
+
import os
|
| 3 |
import subprocess
|
| 4 |
import threading
|
| 5 |
import time
|
|
|
|
| 13 |
from fastapi.staticfiles import StaticFiles
|
| 14 |
|
| 15 |
from core.config import GRADIO_SPACE_URL, logger
|
| 16 |
+
from routers import auth, catalog, media, pages, segmentation, sessions, share
|
| 17 |
+
from routers.catalog import seed_catalog
|
| 18 |
from services.sam2_service import lifespan
|
| 19 |
|
| 20 |
mimetypes.add_type("application/javascript", ".js", strict=True)
|
|
|
|
| 29 |
CORSMiddleware,
|
| 30 |
allow_origins=["*"],
|
| 31 |
allow_credentials=True,
|
| 32 |
+
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
| 33 |
allow_headers=["*"],
|
| 34 |
)
|
| 35 |
|
|
|
|
| 49 |
app.include_router(share.router)
|
| 50 |
app.include_router(media.router)
|
| 51 |
app.include_router(catalog.router)
|
| 52 |
+
app.include_router(sessions.router)
|
| 53 |
app.include_router(segmentation.router)
|
| 54 |
|
| 55 |
# Static files
|
|
|
|
| 108 |
last_state = current_state
|
| 109 |
|
| 110 |
|
| 111 |
+
@app.on_event("startup")
|
| 112 |
+
async def startup_seed_catalog():
|
| 113 |
+
if MONGODB_URI := os.getenv("MONGODB_URI", ""):
|
| 114 |
+
try:
|
| 115 |
+
await seed_catalog()
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
logger.warning("[STARTUP] seed_catalog fallΓ³: %s", exc)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
@app.on_event("startup")
|
| 121 |
async def startup_watch_frontend():
|
| 122 |
thread = threading.Thread(target=watch_frontend_changes, daemon=True)
|
backend/routers/__pycache__/catalog.cpython-312.pyc
CHANGED
|
Binary files a/backend/routers/__pycache__/catalog.cpython-312.pyc and b/backend/routers/__pycache__/catalog.cpython-312.pyc differ
|
|
|
backend/routers/__pycache__/pages.cpython-312.pyc
CHANGED
|
Binary files a/backend/routers/__pycache__/pages.cpython-312.pyc and b/backend/routers/__pycache__/pages.cpython-312.pyc differ
|
|
|
backend/routers/__pycache__/sessions.cpython-312.pyc
ADDED
|
Binary file (5.23 kB). View file
|
|
|
backend/routers/catalog.py
CHANGED
|
@@ -1,150 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from fastapi import APIRouter
|
| 2 |
from fastapi.responses import JSONResponse
|
|
|
|
|
|
|
| 3 |
|
| 4 |
router = APIRouter(prefix="/api/catalog")
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
{
|
| 8 |
-
"
|
| 9 |
"nombre": "ACM (Aluminio Compuesto)",
|
|
|
|
| 10 |
"descripcion": "Paneles de aluminio compuesto para fachadas y exteriores",
|
| 11 |
"especificaciones": [
|
| 12 |
"Espesor de ACM 4mm.",
|
| 13 |
"Medida 1.22m x 2.44m",
|
| 14 |
-
"
|
| 15 |
"Espesor de Aluminio 0.40mm.",
|
| 16 |
"Se puede doblar o biselar",
|
| 17 |
],
|
| 18 |
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/16",
|
| 19 |
"productos": [
|
| 20 |
-
{
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
},
|
| 27 |
-
{
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
},
|
| 34 |
-
{
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
},
|
| 55 |
-
{
|
| 56 |
-
|
| 57 |
-
"nombre": "Azul",
|
| 58 |
-
"textura": "Texture_ACM/ACM_Azul.png",
|
| 59 |
-
"url_preview": "/seg/texture-preview/Texture_ACM/ACM_Azul.png",
|
| 60 |
-
"dimensiones": ["1.22x2.44", "1.50x4.98"],
|
| 61 |
-
},
|
| 62 |
-
{
|
| 63 |
-
"id": "acm_verde_hn",
|
| 64 |
-
"nombre": "Verde HN",
|
| 65 |
-
"textura": "Texture_ACM/ACM_Verde_HN.png",
|
| 66 |
-
"url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_HN.png",
|
| 67 |
-
"dimensiones": ["1.22x2.44"],
|
| 68 |
-
},
|
| 69 |
-
{
|
| 70 |
-
"id": "acm_verde_lima",
|
| 71 |
-
"nombre": "Verde Lima",
|
| 72 |
-
"textura": "Texture_ACM/ACM_Verde_Lima.png",
|
| 73 |
-
"url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_Lima.png",
|
| 74 |
-
"dimensiones": ["1.22x2.44", "1.50x4.98"],
|
| 75 |
-
},
|
| 76 |
-
{
|
| 77 |
-
"id": "acm_verde",
|
| 78 |
-
"nombre": "Verde",
|
| 79 |
-
"textura": "Texture_ACM/ACM_Verde.png",
|
| 80 |
-
"url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde.png",
|
| 81 |
-
"dimensiones": ["1.22x2.44", "1.50x4.98"],
|
| 82 |
-
},
|
| 83 |
-
{
|
| 84 |
-
"id": "acm_madera_clara",
|
| 85 |
-
"nombre": "Madera Clara",
|
| 86 |
-
"textura": "Texture_ACM/ACM_Madera_Clara.png",
|
| 87 |
-
"url_preview": "/seg/texture-preview/Texture_ACM/ACM_Madera_Clara.png",
|
| 88 |
-
"dimensiones": ["1.22x2.44"],
|
| 89 |
-
},
|
| 90 |
-
{
|
| 91 |
-
"id": "acm_roble",
|
| 92 |
-
"nombre": "Roble (Oak)",
|
| 93 |
-
"textura": "Texture_ACM/ACM_ROBLE(OAK).png",
|
| 94 |
-
"url_preview": "/seg/texture-preview/Texture_ACM/ACM_ROBLE(OAK).png",
|
| 95 |
-
"dimensiones": ["1.22x2.44"],
|
| 96 |
-
},
|
| 97 |
-
{
|
| 98 |
-
"id": "acm_grafito",
|
| 99 |
-
"nombre": "Grafito",
|
| 100 |
-
"textura": "Texture_ACM/ACM_Grafito.png",
|
| 101 |
-
"url_preview": "/seg/texture-preview/Texture_ACM/ACM_Grafito.png",
|
| 102 |
-
"dimensiones": ["1.22x2.44", "1.50x4.98"],
|
| 103 |
-
},
|
| 104 |
-
{
|
| 105 |
-
"id": "acm_metalic",
|
| 106 |
-
"nombre": "Silver Metallic",
|
| 107 |
-
"textura": "Texture_ACM/ACM_Metalic.png",
|
| 108 |
-
"url_preview": "/seg/texture-preview/Texture_ACM/ACM_Metalic.png",
|
| 109 |
-
"dimensiones": ["1.22x2.44", "1.50x4.98"],
|
| 110 |
-
},
|
| 111 |
-
{
|
| 112 |
-
"id": "acm_mouse_grey",
|
| 113 |
-
"nombre": "Mouse Grey",
|
| 114 |
-
"textura": "Texture_ACM/ACM_MouseGrey.png",
|
| 115 |
-
"url_preview": "/seg/texture-preview/Texture_ACM/ACM_MouseGrey.png",
|
| 116 |
-
"dimensiones": ["1.22x2.44", "1.50x4.98"],
|
| 117 |
-
},
|
| 118 |
-
{
|
| 119 |
-
"id": "acm_matte_black",
|
| 120 |
-
"nombre": "Matte Black",
|
| 121 |
-
"textura": "Texture_ACM/ACM_Matteblack.png",
|
| 122 |
-
"url_preview": "/seg/texture-preview/Texture_ACM/ACM_Matteblack.png",
|
| 123 |
-
"dimensiones": ["1.22x2.44", "1.50x4.98"],
|
| 124 |
-
},
|
| 125 |
-
{
|
| 126 |
-
"id": "acm_glossy_black",
|
| 127 |
-
"nombre": "Glossy Black",
|
| 128 |
-
"textura": "Texture_ACM/ACM_Glossy_Black.png",
|
| 129 |
-
"url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Black.png",
|
| 130 |
-
"dimensiones": ["1.22x2.44", "1.50x4.98"],
|
| 131 |
-
},
|
| 132 |
],
|
|
|
|
| 133 |
},
|
| 134 |
]
|
| 135 |
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
@router.get("/textures")
|
| 138 |
async def get_texture_catalog() -> JSONResponse:
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
@router.get("/textures/{category_id}")
|
| 143 |
async def get_texture_category(category_id: str) -> JSONResponse:
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
|
| 4 |
from fastapi import APIRouter
|
| 5 |
from fastapi.responses import JSONResponse
|
| 6 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
|
| 9 |
router = APIRouter(prefix="/api/catalog")
|
| 10 |
|
| 11 |
+
MONGODB_URI = os.getenv("MONGODB_URI", "")
|
| 12 |
+
_client: AsyncIOMotorClient | None = None
|
| 13 |
+
_db = None
|
| 14 |
+
_col = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _get_col():
|
| 18 |
+
global _client, _db, _col
|
| 19 |
+
if _col is None:
|
| 20 |
+
if not MONGODB_URI:
|
| 21 |
+
raise RuntimeError("MONGODB_URI no configurado")
|
| 22 |
+
_client = AsyncIOMotorClient(MONGODB_URI)
|
| 23 |
+
_db = _client["hyper_reality"]
|
| 24 |
+
_col = _db["catalog"]
|
| 25 |
+
return _col
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ββ Datos iniciales (se insertan solo si la colecciΓ³n estΓ‘ vacΓa) βββββββββββββ
|
| 29 |
+
_SEED = [
|
| 30 |
{
|
| 31 |
+
"_id": "acm",
|
| 32 |
"nombre": "ACM (Aluminio Compuesto)",
|
| 33 |
+
"tipo": "paredes",
|
| 34 |
"descripcion": "Paneles de aluminio compuesto para fachadas y exteriores",
|
| 35 |
"especificaciones": [
|
| 36 |
"Espesor de ACM 4mm.",
|
| 37 |
"Medida 1.22m x 2.44m",
|
| 38 |
+
"Facil Mantenimiento.",
|
| 39 |
"Espesor de Aluminio 0.40mm.",
|
| 40 |
"Se puede doblar o biselar",
|
| 41 |
],
|
| 42 |
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/16",
|
| 43 |
"productos": [
|
| 44 |
+
{"id": "acm_white", "nombre": "Glossy White", "textura": "Texture_ACM/ACM_White.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_White.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 45 |
+
{"id": "acm_amarillo", "nombre": "Amarillo", "textura": "Texture_ACM/ACM_Amarillo.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Amarillo.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 46 |
+
{"id": "acm_orange", "nombre": "Glossy Orange", "textura": "Texture_ACM/ACM_Orange.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Orange.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 47 |
+
{"id": "acm_red", "nombre": "Glossy Red", "textura": "Texture_ACM/ACM_Glossy_Red.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Red.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 48 |
+
{"id": "acm_light_blue", "nombre": "Light Blue", "textura": "Texture_ACM/ACM_Light_Blue.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Light_Blue.png", "dimensiones": ["1.22x2.44"]},
|
| 49 |
+
{"id": "acm_azul", "nombre": "Azul", "textura": "Texture_ACM/ACM_Azul.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Azul.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 50 |
+
{"id": "acm_verde_hn", "nombre": "Verde HN", "textura": "Texture_ACM/ACM_Verde_HN.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_HN.png", "dimensiones": ["1.22x2.44"]},
|
| 51 |
+
{"id": "acm_verde_lima", "nombre": "Verde Lima", "textura": "Texture_ACM/ACM_Verde_Lima.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_Lima.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 52 |
+
{"id": "acm_verde", "nombre": "Verde", "textura": "Texture_ACM/ACM_Verde.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 53 |
+
{"id": "acm_madera_clara","nombre": "Madera Clara", "textura": "Texture_ACM/ACM_Madera_Clara.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Madera_Clara.png", "dimensiones": ["1.22x2.44"]},
|
| 54 |
+
{"id": "acm_roble", "nombre": "Roble (Oak)", "textura": "Texture_ACM/ACM_ROBLE(OAK).png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_ROBLE(OAK).png", "dimensiones": ["1.22x2.44"]},
|
| 55 |
+
{"id": "acm_grafito", "nombre": "Grafito", "textura": "Texture_ACM/ACM_Grafito.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Grafito.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 56 |
+
{"id": "acm_metalic", "nombre": "Silver Metallic", "textura": "Texture_ACM/ACM_Metalic.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Metalic.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 57 |
+
{"id": "acm_mouse_grey", "nombre": "Mouse Grey", "textura": "Texture_ACM/ACM_MouseGrey.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_MouseGrey.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 58 |
+
{"id": "acm_matte_black", "nombre": "Matte Black", "textura": "Texture_ACM/ACM_Matteblack.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Matteblack.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 59 |
+
{"id": "acm_glossy_black","nombre": "Glossy Black", "textura": "Texture_ACM/ACM_Glossy_Black.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Black.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 60 |
+
],
|
| 61 |
+
"created_at": "2026-04-20T00:00:00Z",
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"_id": "wpc",
|
| 65 |
+
"nombre": "WPC (Exterior e Interior)",
|
| 66 |
+
"tipo": "paredes",
|
| 67 |
+
"descripcion": "Los paneles de WPC se utilizan como revestimiento decorativo para paredes. No se deforma, no requiere mantenimiento constante y tiene mayor durabilidad. Crea mayor estetica e instalacion rapida.",
|
| 68 |
+
"especificaciones": [
|
| 69 |
+
"Revestimiento decorativo para paredes.",
|
| 70 |
+
"No se deforma ni requiere mantenimiento constante.",
|
| 71 |
+
"Mayor durabilidad y estetica.",
|
| 72 |
+
"Instalacion rapida.",
|
| 73 |
+
"Ideal para: sala principal (pared protagonista), comedor, interior de oficina, pasillo o entradas.",
|
| 74 |
+
],
|
| 75 |
+
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/39",
|
| 76 |
+
"productos": [
|
| 77 |
+
{"id": "WPC_madera_oscuro", "nombre": "WPC Madera Oscuro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "dimensiones": ["2.90x0.25"]},
|
| 78 |
+
{"id": "WPC_madera_claro", "nombre": "WPC Madera Claro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "dimensiones": ["2.90x0.25"]},
|
| 79 |
+
{"id": "WPC_madera_gris", "nombre": "WPC Madera Gris", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "dimensiones": ["2.90x0.25"]},
|
| 80 |
+
{"id": "WPC_negro", "nombre": "WPC Negro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "dimensiones": ["2.90x0.25"]},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
],
|
| 82 |
+
"created_at": "2026-05-07T00:00:00Z",
|
| 83 |
},
|
| 84 |
]
|
| 85 |
|
| 86 |
|
| 87 |
+
async def seed_catalog() -> None:
|
| 88 |
+
col = _get_col()
|
| 89 |
+
count = await col.count_documents({})
|
| 90 |
+
if count == 0:
|
| 91 |
+
await col.insert_many(_SEED)
|
| 92 |
+
return
|
| 93 |
+
|
| 94 |
+
# Migrar documentos existentes: aΓ±adir campos que falten segΓΊn _SEED
|
| 95 |
+
for seed_item in _SEED:
|
| 96 |
+
doc = await col.find_one({"_id": seed_item["_id"]})
|
| 97 |
+
if not doc:
|
| 98 |
+
continue
|
| 99 |
+
patch = {k: v for k, v in seed_item.items() if k not in doc and k != "_id"}
|
| 100 |
+
if patch:
|
| 101 |
+
await col.update_one({"_id": seed_item["_id"]}, {"$set": patch})
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _serialize(doc: dict) -> dict:
|
| 105 |
+
out = dict(doc)
|
| 106 |
+
out["id"] = str(out.pop("_id"))
|
| 107 |
+
return out
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _seed_as_response() -> list[dict]:
|
| 111 |
+
return [{**{k: v for k, v in item.items() if k != "_id"}, "id": item["_id"]} for item in _SEED]
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ββ Endpoints de lectura ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
+
|
| 116 |
@router.get("/textures")
|
| 117 |
async def get_texture_catalog() -> JSONResponse:
|
| 118 |
+
try:
|
| 119 |
+
col = _get_col()
|
| 120 |
+
docs = await col.find({}).to_list(length=200)
|
| 121 |
+
if docs:
|
| 122 |
+
return JSONResponse(content={"categories": [_serialize(d) for d in docs]})
|
| 123 |
+
except Exception:
|
| 124 |
+
pass
|
| 125 |
+
# Fallback a datos estΓ‘ticos si MongoDB no estΓ‘ disponible o la colecciΓ³n estΓ‘ vacΓa
|
| 126 |
+
return JSONResponse(content={"categories": _seed_as_response()})
|
| 127 |
|
| 128 |
|
| 129 |
@router.get("/textures/{category_id}")
|
| 130 |
async def get_texture_category(category_id: str) -> JSONResponse:
|
| 131 |
+
try:
|
| 132 |
+
col = _get_col()
|
| 133 |
+
doc = await col.find_one({"_id": category_id})
|
| 134 |
+
if doc:
|
| 135 |
+
return JSONResponse(content=_serialize(doc))
|
| 136 |
+
except Exception:
|
| 137 |
+
pass
|
| 138 |
+
fallback = next((item for item in _SEED if item["_id"] == category_id), None)
|
| 139 |
+
if fallback:
|
| 140 |
+
return JSONResponse(content=_seed_as_response()[_SEED.index(fallback)])
|
| 141 |
+
return JSONResponse(content={"detail": f"Categoria '{category_id}' no encontrada"}, status_code=404)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ββ Modelos βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 145 |
+
|
| 146 |
+
class ProductoItem(BaseModel):
|
| 147 |
+
id: str
|
| 148 |
+
nombre: str
|
| 149 |
+
textura: str
|
| 150 |
+
url_preview: str
|
| 151 |
+
dimensiones: list[str] = []
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
class CategoriaBody(BaseModel):
|
| 155 |
+
id: str
|
| 156 |
+
nombre: str
|
| 157 |
+
tipo: str = "paredes"
|
| 158 |
+
descripcion: str = ""
|
| 159 |
+
especificaciones: list[str] = []
|
| 160 |
+
url_detalle: str = ""
|
| 161 |
+
productos: list[ProductoItem] = []
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ββ Endpoints de escritura ββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½βββββ
|
| 165 |
+
|
| 166 |
+
@router.post("/category")
|
| 167 |
+
async def add_category(body: CategoriaBody) -> JSONResponse:
|
| 168 |
+
col = _get_col()
|
| 169 |
+
existing = await col.find_one({"_id": body.id})
|
| 170 |
+
if existing:
|
| 171 |
+
return JSONResponse(content={"error": f"Categoria '{body.id}' ya existe"}, status_code=409)
|
| 172 |
+
doc = body.model_dump()
|
| 173 |
+
doc["_id"] = doc.pop("id")
|
| 174 |
+
doc["created_at"] = datetime.utcnow().isoformat() + "Z"
|
| 175 |
+
await col.insert_one(doc)
|
| 176 |
+
return JSONResponse(content={"ok": True, "id": body.id}, status_code=201)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@router.put("/category/{category_id}")
|
| 180 |
+
async def update_category(category_id: str, body: CategoriaBody) -> JSONResponse:
|
| 181 |
+
col = _get_col()
|
| 182 |
+
doc = body.model_dump()
|
| 183 |
+
doc.pop("id", None)
|
| 184 |
+
doc["updated_at"] = datetime.utcnow().isoformat() + "Z"
|
| 185 |
+
result = await col.update_one({"_id": category_id}, {"$set": doc})
|
| 186 |
+
if result.matched_count == 0:
|
| 187 |
+
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 188 |
+
return JSONResponse(content={"ok": True})
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@router.delete("/category/{category_id}")
|
| 192 |
+
async def delete_category(category_id: str) -> JSONResponse:
|
| 193 |
+
col = _get_col()
|
| 194 |
+
result = await col.delete_one({"_id": category_id})
|
| 195 |
+
if result.deleted_count == 0:
|
| 196 |
+
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 197 |
+
return JSONResponse(content={"ok": True, "deleted": category_id})
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@router.post("/category/{category_id}/product")
|
| 201 |
+
async def add_product(category_id: str, product: ProductoItem) -> JSONResponse:
|
| 202 |
+
col = _get_col()
|
| 203 |
+
result = await col.update_one(
|
| 204 |
+
{"_id": category_id, "productos.id": {"$ne": product.id}},
|
| 205 |
+
{"$push": {"productos": product.model_dump()}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
|
| 206 |
+
)
|
| 207 |
+
if result.matched_count == 0:
|
| 208 |
+
return JSONResponse(content={"error": "Categoria no encontrada o producto duplicado"}, status_code=409)
|
| 209 |
+
return JSONResponse(content={"ok": True}, status_code=201)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@router.delete("/category/{category_id}/product/{product_id}")
|
| 213 |
+
async def delete_product(category_id: str, product_id: str) -> JSONResponse:
|
| 214 |
+
col = _get_col()
|
| 215 |
+
result = await col.update_one(
|
| 216 |
+
{"_id": category_id},
|
| 217 |
+
{"$pull": {"productos": {"id": product_id}}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
|
| 218 |
+
)
|
| 219 |
+
if result.matched_count == 0:
|
| 220 |
+
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 221 |
+
return JSONResponse(content={"ok": True})
|
backend/routers/sessions.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter
|
| 5 |
+
from fastapi.responses import JSONResponse
|
| 6 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api/sessions")
|
| 10 |
+
|
| 11 |
+
MONGODB_URI = os.getenv("MONGODB_URI", "")
|
| 12 |
+
_client: AsyncIOMotorClient | None = None
|
| 13 |
+
_db = None
|
| 14 |
+
_col = None
|
| 15 |
+
|
| 16 |
+
MAX_ITEMS = 10
|
| 17 |
+
ITEM_TTL_DAYS = 30
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _get_col():
|
| 21 |
+
global _client, _db, _col
|
| 22 |
+
if _col is None:
|
| 23 |
+
if not MONGODB_URI:
|
| 24 |
+
raise RuntimeError("MONGODB_URI no configurado")
|
| 25 |
+
_client = AsyncIOMotorClient(MONGODB_URI)
|
| 26 |
+
_db = _client["hyper_reality"]
|
| 27 |
+
_col = _db["sessions"]
|
| 28 |
+
return _col
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class SessionItem(BaseModel):
|
| 32 |
+
filename: str
|
| 33 |
+
previewUrl: str
|
| 34 |
+
maskCount: int
|
| 35 |
+
uploadedAt: int # ms timestamp
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get("/{user_id}")
|
| 39 |
+
async def get_sessions(user_id: str):
|
| 40 |
+
try:
|
| 41 |
+
col = _get_col()
|
| 42 |
+
doc = await col.find_one({"_id": user_id})
|
| 43 |
+
if not doc:
|
| 44 |
+
return JSONResponse(content={"items": []})
|
| 45 |
+
|
| 46 |
+
cutoff_ms = int((datetime.utcnow() - timedelta(days=ITEM_TTL_DAYS)).timestamp() * 1000)
|
| 47 |
+
items = [i for i in doc.get("items", []) if i.get("uploadedAt", 0) > cutoff_ms]
|
| 48 |
+
|
| 49 |
+
return JSONResponse(content={"items": items})
|
| 50 |
+
except Exception as exc:
|
| 51 |
+
return JSONResponse(content={"items": [], "error": str(exc)})
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@router.delete("/{user_id}/{filename:path}")
|
| 55 |
+
async def delete_session_item(user_id: str, filename: str):
|
| 56 |
+
try:
|
| 57 |
+
col = _get_col()
|
| 58 |
+
result = await col.update_one(
|
| 59 |
+
{"_id": user_id},
|
| 60 |
+
{"$pull": {"items": {"filename": filename}}},
|
| 61 |
+
)
|
| 62 |
+
if result.matched_count == 0:
|
| 63 |
+
return JSONResponse(content={"ok": False, "error": "Usuario no encontrado"}, status_code=404)
|
| 64 |
+
return JSONResponse(content={"ok": True})
|
| 65 |
+
except Exception as exc:
|
| 66 |
+
return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@router.post("/{user_id}")
|
| 70 |
+
async def save_session(user_id: str, item: SessionItem):
|
| 71 |
+
try:
|
| 72 |
+
col = _get_col()
|
| 73 |
+
|
| 74 |
+
doc = await col.find_one({"_id": user_id}) or {"_id": user_id, "items": []}
|
| 75 |
+
|
| 76 |
+
cutoff_ms = int((datetime.utcnow() - timedelta(days=ITEM_TTL_DAYS)).timestamp() * 1000)
|
| 77 |
+
items = [
|
| 78 |
+
i for i in doc.get("items", [])
|
| 79 |
+
if i.get("filename") != item.filename and i.get("uploadedAt", 0) > cutoff_ms
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
items = [item.model_dump()] + items
|
| 83 |
+
items = items[:MAX_ITEMS]
|
| 84 |
+
|
| 85 |
+
await col.replace_one(
|
| 86 |
+
{"_id": user_id},
|
| 87 |
+
{"_id": user_id, "items": items, "updated_at": datetime.utcnow().isoformat() + "Z"},
|
| 88 |
+
upsert=True,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
return JSONResponse(content={"ok": True, "count": len(items)})
|
| 92 |
+
except Exception as exc:
|
| 93 |
+
return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)
|
backend/services/__pycache__/image_service.cpython-312.pyc
CHANGED
|
Binary files a/backend/services/__pycache__/image_service.cpython-312.pyc and b/backend/services/__pycache__/image_service.cpython-312.pyc differ
|
|
|
backend/services/__pycache__/texture_service.cpython-312.pyc
CHANGED
|
Binary files a/backend/services/__pycache__/texture_service.cpython-312.pyc and b/backend/services/__pycache__/texture_service.cpython-312.pyc differ
|
|
|
backend/services/texture_service.py
CHANGED
|
@@ -302,7 +302,7 @@ def _tile_texture_perspective(
|
|
| 302 |
|
| 303 |
def classify_texture_material(texture_name: str) -> str:
|
| 304 |
texture_key = texture_name.lower()
|
| 305 |
-
if "acm" in texture_key:
|
| 306 |
return "acm"
|
| 307 |
if any(hint in texture_key for hint in ("deck", "wood", "plank", "laminate", "floor")):
|
| 308 |
return "wood"
|
|
|
|
| 302 |
|
| 303 |
def classify_texture_material(texture_name: str) -> str:
|
| 304 |
texture_key = texture_name.lower()
|
| 305 |
+
if "acm" in texture_key or "wpc" in texture_key:
|
| 306 |
return "acm"
|
| 307 |
if any(hint in texture_key for hint in ("deck", "wood", "plank", "laminate", "floor")):
|
| 308 |
return "wood"
|
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png
ADDED
|
Git LFS Details
|
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png
ADDED
|
Git LFS Details
|
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png
ADDED
|
Git LFS Details
|
backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png
ADDED
|
Git LFS Details
|
frontend/src/features/roomSetup/RoomSetup.tsx
CHANGED
|
@@ -6,16 +6,38 @@ import {
|
|
| 6 |
Camera as CameraIcon,
|
| 7 |
UploadCloud,
|
| 8 |
Users,
|
|
|
|
| 9 |
} from "lucide-react";
|
|
|
|
| 10 |
import { categorias, habitaciones } from "../../data/roomSetupData";
|
| 11 |
import { FilterButton, RoomCard } from "./RoomSetupComponents";
|
| 12 |
import { useRoomSetup } from "./roomSetupHooks";
|
| 13 |
import useAppStore from "../../store/useAppStore";
|
|
|
|
| 14 |
import { useActiveSessions } from "../../hooks/useActiveSessions";
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
export default function RoomSetup() {
|
|
|
|
| 17 |
const [categoriaActiva, setCategoriaActiva] = useState("todos");
|
|
|
|
| 18 |
const segmentProgress = useAppStore((s) => s.segmentProgress);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const { count: activeSessions } = useActiveSessions();
|
| 20 |
const {
|
| 21 |
isDragging,
|
|
@@ -62,7 +84,8 @@ export default function RoomSetup() {
|
|
| 62 |
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
| 63 |
<Users className="w-4 h-4 text-[#0047AB]" />
|
| 64 |
<span className="text-sm font-medium text-[#0047AB]">
|
| 65 |
-
{activeSessions} usuario{activeSessions !== 1 ? "s" : ""} activo
|
|
|
|
| 66 |
</span>
|
| 67 |
</div>
|
| 68 |
</div>
|
|
@@ -87,14 +110,38 @@ export default function RoomSetup() {
|
|
| 87 |
</li>
|
| 88 |
</ul>
|
| 89 |
|
| 90 |
-
<div className="w-full">
|
| 91 |
<button
|
| 92 |
onClick={triggerFileInput}
|
| 93 |
className="w-full bg-[#333333] hover:bg-black text-white font-bold text-base sm:text-lg py-3 sm:py-4 px-6 rounded-xl flex items-center justify-center gap-3 transition-colors shadow-sm"
|
| 94 |
>
|
| 95 |
-
<CameraIcon
|
|
|
|
|
|
|
|
|
|
| 96 |
Sube tu foto
|
| 97 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
{uploadMessage && (
|
| 100 |
<p className="text-sm text-[#707070] mt-3">{uploadMessage}</p>
|
|
@@ -164,6 +211,78 @@ export default function RoomSetup() {
|
|
| 164 |
</div>
|
| 165 |
</section>
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
{/* SECCIΓN INFERIOR: Habitaciones de demostraciΓ³n */}
|
| 168 |
<section>
|
| 169 |
<h2 className="text-xl sm:text-2xl font-bold text-[#0047AB] mb-4 sm:mb-6">
|
|
|
|
| 6 |
Camera as CameraIcon,
|
| 7 |
UploadCloud,
|
| 8 |
Users,
|
| 9 |
+
ArrowRight,
|
| 10 |
} from "lucide-react";
|
| 11 |
+
import { useNavigate } from "react-router-dom";
|
| 12 |
import { categorias, habitaciones } from "../../data/roomSetupData";
|
| 13 |
import { FilterButton, RoomCard } from "./RoomSetupComponents";
|
| 14 |
import { useRoomSetup } from "./roomSetupHooks";
|
| 15 |
import useAppStore from "../../store/useAppStore";
|
| 16 |
+
import { useHistoryStore, type HistoryItem } from "../../store/useAppStore";
|
| 17 |
import { useActiveSessions } from "../../hooks/useActiveSessions";
|
| 18 |
+
import { API_BASE } from "../../api/client";
|
| 19 |
+
import { useLoadSessionHistory, deleteSessionFromBackend } from "../../hooks/useSessionSync";
|
| 20 |
+
import { useCallback } from "react";
|
| 21 |
|
| 22 |
export default function RoomSetup() {
|
| 23 |
+
useLoadSessionHistory();
|
| 24 |
const [categoriaActiva, setCategoriaActiva] = useState("todos");
|
| 25 |
+
const navigate = useNavigate();
|
| 26 |
const segmentProgress = useAppStore((s) => s.segmentProgress);
|
| 27 |
+
const segmentFilename = useAppStore((s) => s.segmentFilename);
|
| 28 |
+
const storedPreviewImage = useAppStore((s) => s.previewImage);
|
| 29 |
+
const sessionHistory = useHistoryStore((s) => s.sessionHistory);
|
| 30 |
+
const removeFromHistory = useHistoryStore((s) => s.removeFromHistory);
|
| 31 |
+
const userId = useHistoryStore((s) => s.userId);
|
| 32 |
+
|
| 33 |
+
const handleDeleteSession = useCallback(
|
| 34 |
+
(e: React.MouseEvent, filename: string) => {
|
| 35 |
+
e.stopPropagation();
|
| 36 |
+
removeFromHistory(filename);
|
| 37 |
+
deleteSessionFromBackend(userId, filename);
|
| 38 |
+
},
|
| 39 |
+
[removeFromHistory, userId],
|
| 40 |
+
);
|
| 41 |
const { count: activeSessions } = useActiveSessions();
|
| 42 |
const {
|
| 43 |
isDragging,
|
|
|
|
| 84 |
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
| 85 |
<Users className="w-4 h-4 text-[#0047AB]" />
|
| 86 |
<span className="text-sm font-medium text-[#0047AB]">
|
| 87 |
+
{activeSessions} usuario{activeSessions !== 1 ? "s" : ""} activo
|
| 88 |
+
{activeSessions !== 1 ? "s" : ""}
|
| 89 |
</span>
|
| 90 |
</div>
|
| 91 |
</div>
|
|
|
|
| 110 |
</li>
|
| 111 |
</ul>
|
| 112 |
|
| 113 |
+
<div className="w-full flex flex-col gap-3">
|
| 114 |
<button
|
| 115 |
onClick={triggerFileInput}
|
| 116 |
className="w-full bg-[#333333] hover:bg-black text-white font-bold text-base sm:text-lg py-3 sm:py-4 px-6 rounded-xl flex items-center justify-center gap-3 transition-colors shadow-sm"
|
| 117 |
>
|
| 118 |
+
<CameraIcon
|
| 119 |
+
className="w-5 h-5 sm:w-6 sm:h-6"
|
| 120 |
+
strokeWidth={2.5}
|
| 121 |
+
/>
|
| 122 |
Sube tu foto
|
| 123 |
</button>
|
| 124 |
+
|
| 125 |
+
{segmentFilename && !isUploading && (
|
| 126 |
+
<button
|
| 127 |
+
onClick={() =>
|
| 128 |
+
navigate("/visualizer", {
|
| 129 |
+
state: {
|
| 130 |
+
previewImage:
|
| 131 |
+
storedPreviewImage ??
|
| 132 |
+
`${API_BASE}/seg/image/${segmentFilename}`,
|
| 133 |
+
},
|
| 134 |
+
})
|
| 135 |
+
}
|
| 136 |
+
className="w-full bg-[#0047AB] hover:bg-[#003a94] text-white font-bold text-base sm:text-lg py-3 sm:py-4 px-6 rounded-xl flex items-center justify-center gap-3 transition-colors shadow-sm"
|
| 137 |
+
>
|
| 138 |
+
Continuar al visualizador
|
| 139 |
+
<ArrowRight
|
| 140 |
+
className="w-5 h-5 sm:w-6 sm:h-6"
|
| 141 |
+
strokeWidth={2.5}
|
| 142 |
+
/>
|
| 143 |
+
</button>
|
| 144 |
+
)}
|
| 145 |
</div>
|
| 146 |
{uploadMessage && (
|
| 147 |
<p className="text-sm text-[#707070] mt-3">{uploadMessage}</p>
|
|
|
|
| 211 |
</div>
|
| 212 |
</section>
|
| 213 |
|
| 214 |
+
{/* SECCIΓN HISTORIAL DE SESIΓN */}
|
| 215 |
+
{sessionHistory.length > 0 && (
|
| 216 |
+
<section className="mb-10 sm:mb-14">
|
| 217 |
+
<h2 className="text-xl sm:text-2xl font-bold text-[#333333] mb-4 sm:mb-6">
|
| 218 |
+
Tus espacios recientes
|
| 219 |
+
</h2>
|
| 220 |
+
<div
|
| 221 |
+
className="flex gap-4 overflow-x-auto pb-2"
|
| 222 |
+
style={{ scrollbarWidth: "thin" }}
|
| 223 |
+
>
|
| 224 |
+
{sessionHistory.map((item: HistoryItem) => (
|
| 225 |
+
<button
|
| 226 |
+
key={item.filename}
|
| 227 |
+
onClick={() =>
|
| 228 |
+
navigate("/visualizer", {
|
| 229 |
+
state: {
|
| 230 |
+
previewImage: item.previewUrl,
|
| 231 |
+
filename: item.filename,
|
| 232 |
+
maskCount: item.maskCount,
|
| 233 |
+
},
|
| 234 |
+
})
|
| 235 |
+
}
|
| 236 |
+
className={`flex-shrink-0 group relative rounded-2xl overflow-hidden border-2 transition-all duration-200 ${
|
| 237 |
+
segmentFilename === item.filename
|
| 238 |
+
? "border-[#0047AB] shadow-lg shadow-[#0047AB]/20"
|
| 239 |
+
: "border-[#dbe7ff] hover:border-[#0047AB]"
|
| 240 |
+
}`}
|
| 241 |
+
style={{ width: 160, height: 120 }}
|
| 242 |
+
>
|
| 243 |
+
<img
|
| 244 |
+
src={item.previewUrl}
|
| 245 |
+
alt="HabitaciΓ³n previa"
|
| 246 |
+
className="w-full h-full object-cover"
|
| 247 |
+
/>
|
| 248 |
+
{/* Overlay con hora */}
|
| 249 |
+
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent flex flex-col justify-end p-2">
|
| 250 |
+
<p className="text-white/80 text-[10px]">
|
| 251 |
+
{new Date(item.uploadedAt).toLocaleDateString([], {
|
| 252 |
+
day: "2-digit",
|
| 253 |
+
month: "short",
|
| 254 |
+
})}{" "}
|
| 255 |
+
{new Date(item.uploadedAt).toLocaleTimeString([], {
|
| 256 |
+
hour: "2-digit",
|
| 257 |
+
minute: "2-digit",
|
| 258 |
+
})}
|
| 259 |
+
</p>
|
| 260 |
+
</div>
|
| 261 |
+
{/* Badge "activa" o botΓ³n eliminar */}
|
| 262 |
+
{segmentFilename === item.filename ? (
|
| 263 |
+
<div className="absolute top-2 right-2 bg-[#0047AB] text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full">
|
| 264 |
+
Activa
|
| 265 |
+
</div>
|
| 266 |
+
) : (
|
| 267 |
+
<button
|
| 268 |
+
onClick={(e) => handleDeleteSession(e, item.filename)}
|
| 269 |
+
className="absolute top-2 right-2 w-5 h-5 rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"
|
| 270 |
+
>
|
| 271 |
+
<X className="w-3 h-3" />
|
| 272 |
+
</button>
|
| 273 |
+
)}
|
| 274 |
+
{/* Hover: continuar */}
|
| 275 |
+
<div className="absolute inset-0 bg-[#0047AB]/80 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
| 276 |
+
<span className="text-white text-xs font-bold flex items-center gap-1">
|
| 277 |
+
Continuar <ArrowRight className="w-3.5 h-3.5" />
|
| 278 |
+
</span>
|
| 279 |
+
</div>
|
| 280 |
+
</button>
|
| 281 |
+
))}
|
| 282 |
+
</div>
|
| 283 |
+
</section>
|
| 284 |
+
)}
|
| 285 |
+
|
| 286 |
{/* SECCIΓN INFERIOR: Habitaciones de demostraciΓ³n */}
|
| 287 |
<section>
|
| 288 |
<h2 className="text-xl sm:text-2xl font-bold text-[#0047AB] mb-4 sm:mb-6">
|
frontend/src/features/roomSetup/roomSetupHooks.ts
CHANGED
|
@@ -8,8 +8,10 @@ import {
|
|
| 8 |
} from "react";
|
| 9 |
import { useNavigate } from "react-router-dom";
|
| 10 |
import useAppStore from "../../store/useAppStore";
|
|
|
|
| 11 |
import { useSegmentUpload } from "../../hooks/useSegmentUpload";
|
| 12 |
import { API_BASE } from "../../api/client";
|
|
|
|
| 13 |
|
| 14 |
export function useRoomSetup(): {
|
| 15 |
isDragging: boolean;
|
|
@@ -35,6 +37,8 @@ export function useRoomSetup(): {
|
|
| 35 |
const setUploadMessage = useAppStore((state) => state.setUploadMessage);
|
| 36 |
const setSegmentResult = useAppStore((state) => state.setSegmentResult);
|
| 37 |
const setSegmentProgress = useAppStore((state) => state.setSegmentProgress);
|
|
|
|
|
|
|
| 38 |
|
| 39 |
const { uploadAndSegment, isUploading } = useSegmentUpload();
|
| 40 |
|
|
@@ -67,6 +71,15 @@ export function useRoomSetup(): {
|
|
| 67 |
const serverImageUrl = `${API_BASE}/seg/image/${filename}`;
|
| 68 |
setPreviewImage(serverImageUrl);
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
navigate("/visualizer", {
|
| 71 |
state: { previewImage: serverImageUrl },
|
| 72 |
});
|
|
@@ -83,6 +96,8 @@ export function useRoomSetup(): {
|
|
| 83 |
setSegmentResult,
|
| 84 |
setSegmentProgress,
|
| 85 |
uploadAndSegment,
|
|
|
|
|
|
|
| 86 |
],
|
| 87 |
);
|
| 88 |
|
|
|
|
| 8 |
} from "react";
|
| 9 |
import { useNavigate } from "react-router-dom";
|
| 10 |
import useAppStore from "../../store/useAppStore";
|
| 11 |
+
import { useHistoryStore } from "../../store/useAppStore";
|
| 12 |
import { useSegmentUpload } from "../../hooks/useSegmentUpload";
|
| 13 |
import { API_BASE } from "../../api/client";
|
| 14 |
+
import { saveSessionToBackend } from "../../hooks/useSessionSync";
|
| 15 |
|
| 16 |
export function useRoomSetup(): {
|
| 17 |
isDragging: boolean;
|
|
|
|
| 37 |
const setUploadMessage = useAppStore((state) => state.setUploadMessage);
|
| 38 |
const setSegmentResult = useAppStore((state) => state.setSegmentResult);
|
| 39 |
const setSegmentProgress = useAppStore((state) => state.setSegmentProgress);
|
| 40 |
+
const addToHistory = useHistoryStore((state) => state.addToHistory);
|
| 41 |
+
const userId = useHistoryStore((state) => state.userId);
|
| 42 |
|
| 43 |
const { uploadAndSegment, isUploading } = useSegmentUpload();
|
| 44 |
|
|
|
|
| 71 |
const serverImageUrl = `${API_BASE}/seg/image/${filename}`;
|
| 72 |
setPreviewImage(serverImageUrl);
|
| 73 |
|
| 74 |
+
const historyItem = {
|
| 75 |
+
filename,
|
| 76 |
+
previewUrl: serverImageUrl,
|
| 77 |
+
maskCount,
|
| 78 |
+
uploadedAt: Date.now(),
|
| 79 |
+
};
|
| 80 |
+
addToHistory(historyItem);
|
| 81 |
+
saveSessionToBackend(userId, historyItem);
|
| 82 |
+
|
| 83 |
navigate("/visualizer", {
|
| 84 |
state: { previewImage: serverImageUrl },
|
| 85 |
});
|
|
|
|
| 96 |
setSegmentResult,
|
| 97 |
setSegmentProgress,
|
| 98 |
uploadAndSegment,
|
| 99 |
+
addToHistory,
|
| 100 |
+
userId,
|
| 101 |
],
|
| 102 |
);
|
| 103 |
|
frontend/src/features/roomVisualizer/RoomPreviewPanel.tsx
CHANGED
|
@@ -124,12 +124,31 @@ export function RoomPreviewPanel({
|
|
| 124 |
>
|
| 125 |
<Download className="h-4 w-4 text-[#0047AB]" /> Descargar
|
| 126 |
</button>
|
| 127 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
<MapPin className="h-4 w-4 text-[#0047AB]" /> Encuentra tu tienda
|
| 129 |
-
</
|
| 130 |
-
|
| 131 |
-
<
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
</div>
|
| 134 |
|
| 135 |
{/* ββ Γrea de imagen + canvas ββββββββββββββββββββββββββββββββ */}
|
|
|
|
| 124 |
>
|
| 125 |
<Download className="h-4 w-4 text-[#0047AB]" /> Descargar
|
| 126 |
</button>
|
| 127 |
+
<a
|
| 128 |
+
href="https://nauffargermany.com/gt/sucursales-2/"
|
| 129 |
+
target="_blank"
|
| 130 |
+
rel="noopener noreferrer"
|
| 131 |
+
className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2"
|
| 132 |
+
>
|
| 133 |
<MapPin className="h-4 w-4 text-[#0047AB]" /> Encuentra tu tienda
|
| 134 |
+
</a>
|
| 135 |
+
{selectedProduct?.detailUrl ? (
|
| 136 |
+
<a
|
| 137 |
+
href={selectedProduct.detailUrl}
|
| 138 |
+
target="_blank"
|
| 139 |
+
rel="noopener noreferrer"
|
| 140 |
+
className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-[#0047AB] hover:bg-[#eaf1ff] flex items-center gap-2"
|
| 141 |
+
>
|
| 142 |
+
<ShoppingCart className="h-4 w-4 text-[#0047AB]" /> Ir a la pΓ‘gina del producto
|
| 143 |
+
</a>
|
| 144 |
+
) : (
|
| 145 |
+
<button
|
| 146 |
+
disabled
|
| 147 |
+
className="pointer-events-auto rounded-full bg-transparent px-4 py-2 text-sm font-medium text-gray-300 flex items-center gap-2 cursor-default"
|
| 148 |
+
>
|
| 149 |
+
<ShoppingCart className="h-4 w-4 text-gray-300" /> Ir a la pΓ‘gina del producto
|
| 150 |
+
</button>
|
| 151 |
+
)}
|
| 152 |
</div>
|
| 153 |
|
| 154 |
{/* ββ Γrea de imagen + canvas ββββββββββββββββββββββββββββββββ */}
|
frontend/src/features/roomVisualizer/RoomVisualizer.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
| 5 |
useState,
|
| 6 |
type PointerEvent,
|
| 7 |
} from "react";
|
|
|
|
| 8 |
import { createRoot } from "react-dom/client";
|
| 9 |
import { useLocation, useNavigate } from "react-router-dom";
|
| 10 |
import {
|
|
@@ -33,6 +34,8 @@ import { API_BASE } from "../../api/client";
|
|
| 33 |
|
| 34 |
type RoomVisualizerState = {
|
| 35 |
previewImage?: string;
|
|
|
|
|
|
|
| 36 |
};
|
| 37 |
|
| 38 |
// ββ Hook para detectar mobile (< 1024px) βββββββββββββββββββββββββββββββββββββ
|
|
@@ -56,6 +59,21 @@ export default function RoomVisualizer() {
|
|
| 56 |
const segmentFilename = useAppStore((store) => store.segmentFilename);
|
| 57 |
const accumulatedFilename = useAppStore((store) => store.accumulatedFilename);
|
| 58 |
const setAccumulatedFilename = useAppStore((store) => store.setAccumulatedFilename);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
const [currentPreviewImage, setCurrentPreviewImage] = useState<string | null>(() => {
|
| 61 |
if (accumulatedFilename) return `${API_BASE}/seg/image/${accumulatedFilename}`;
|
|
@@ -68,7 +86,35 @@ export default function RoomVisualizer() {
|
|
| 68 |
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
|
| 69 |
const wrapperRef = useRef<HTMLDivElement>(null);
|
| 70 |
|
| 71 |
-
const { products, loading, error } = useCatalogProducts();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
const {
|
| 74 |
viewMode, showGrid, showList,
|
|
@@ -298,7 +344,7 @@ export default function RoomVisualizer() {
|
|
| 298 |
hoveredMask,
|
| 299 |
segmentMeta,
|
| 300 |
isApplying,
|
| 301 |
-
onBack: () => navigate(
|
| 302 |
onPointerDown: handlePointerDown,
|
| 303 |
onPointerMove: handlePointerMove,
|
| 304 |
onPointerUp: handlePointerUp,
|
|
@@ -474,28 +520,73 @@ export default function RoomVisualizer() {
|
|
| 474 |
)}
|
| 475 |
</div>
|
| 476 |
|
| 477 |
-
{/* Lista de productos */}
|
| 478 |
-
<div className=
|
| 479 |
{loading ? (
|
| 480 |
<div className="flex items-center justify-center h-32 text-sm text-gray-400">Cargando productos...</div>
|
| 481 |
) : error ? (
|
| 482 |
<div className="flex items-center justify-center h-32 text-sm text-red-400">{error}</div>
|
| 483 |
-
) :
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
</div>
|
| 489 |
) : (
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
</div>
|
| 500 |
)}
|
| 501 |
</div>
|
|
|
|
| 5 |
useState,
|
| 6 |
type PointerEvent,
|
| 7 |
} from "react";
|
| 8 |
+
import { ChevronDown } from "lucide-react";
|
| 9 |
import { createRoot } from "react-dom/client";
|
| 10 |
import { useLocation, useNavigate } from "react-router-dom";
|
| 11 |
import {
|
|
|
|
| 34 |
|
| 35 |
type RoomVisualizerState = {
|
| 36 |
previewImage?: string;
|
| 37 |
+
filename?: string;
|
| 38 |
+
maskCount?: number;
|
| 39 |
};
|
| 40 |
|
| 41 |
// ββ Hook para detectar mobile (< 1024px) βββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 59 |
const segmentFilename = useAppStore((store) => store.segmentFilename);
|
| 60 |
const accumulatedFilename = useAppStore((store) => store.accumulatedFilename);
|
| 61 |
const setAccumulatedFilename = useAppStore((store) => store.setAccumulatedFilename);
|
| 62 |
+
const setSegmentResult = useAppStore((store) => store.setSegmentResult);
|
| 63 |
+
const setPreviewImage = useAppStore((store) => store.setPreviewImage);
|
| 64 |
+
|
| 65 |
+
// Restaurar segmentFilename y previewImage cuando se abre una sesiΓ³n del historial
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
if (state?.filename && state.filename !== segmentFilename) {
|
| 68 |
+
setSegmentResult(state.filename, state.maskCount ?? 0);
|
| 69 |
+
setAccumulatedFilename(null); // limpiar ediciones de sesiΓ³n anterior
|
| 70 |
+
}
|
| 71 |
+
if (state?.previewImage) {
|
| 72 |
+
setPreviewImage(state.previewImage);
|
| 73 |
+
}
|
| 74 |
+
// Solo al montar β state no cambia tras la navegaciΓ³n
|
| 75 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 76 |
+
}, []);
|
| 77 |
|
| 78 |
const [currentPreviewImage, setCurrentPreviewImage] = useState<string | null>(() => {
|
| 79 |
if (accumulatedFilename) return `${API_BASE}/seg/image/${accumulatedFilename}`;
|
|
|
|
| 86 |
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
|
| 87 |
const wrapperRef = useRef<HTMLDivElement>(null);
|
| 88 |
|
| 89 |
+
const { products, categories, loading, error } = useCatalogProducts();
|
| 90 |
+
|
| 91 |
+
// null = sin interacciΓ³n (primera categorΓa abierta por defecto)
|
| 92 |
+
// Set vacΓo = usuario cerrΓ³ todo deliberadamente
|
| 93 |
+
const [openCategoryIds, setOpenCategoryIds] = useState<Set<string> | null>(null);
|
| 94 |
+
|
| 95 |
+
const isCategoryOpen = useCallback(
|
| 96 |
+
(id: string) => {
|
| 97 |
+
if (openCategoryIds === null) {
|
| 98 |
+
return categories.length > 0 && id === categories[0].id;
|
| 99 |
+
}
|
| 100 |
+
return openCategoryIds.has(id);
|
| 101 |
+
},
|
| 102 |
+
[openCategoryIds, categories],
|
| 103 |
+
);
|
| 104 |
+
|
| 105 |
+
const toggleCategory = useCallback((id: string) => {
|
| 106 |
+
setOpenCategoryIds((prev) => {
|
| 107 |
+
const base =
|
| 108 |
+
prev === null
|
| 109 |
+
? categories.length > 0
|
| 110 |
+
? new Set([categories[0].id])
|
| 111 |
+
: new Set<string>()
|
| 112 |
+
: new Set(prev);
|
| 113 |
+
if (base.has(id)) base.delete(id);
|
| 114 |
+
else base.add(id);
|
| 115 |
+
return base;
|
| 116 |
+
});
|
| 117 |
+
}, [categories]);
|
| 118 |
|
| 119 |
const {
|
| 120 |
viewMode, showGrid, showList,
|
|
|
|
| 344 |
hoveredMask,
|
| 345 |
segmentMeta,
|
| 346 |
isApplying,
|
| 347 |
+
onBack: () => navigate("/app"),
|
| 348 |
onPointerDown: handlePointerDown,
|
| 349 |
onPointerMove: handlePointerMove,
|
| 350 |
onPointerUp: handlePointerUp,
|
|
|
|
| 520 |
)}
|
| 521 |
</div>
|
| 522 |
|
| 523 |
+
{/* Lista de productos con acordeΓ³n por categorΓa */}
|
| 524 |
+
<div className="flex-1 overflow-y-auto py-2">
|
| 525 |
{loading ? (
|
| 526 |
<div className="flex items-center justify-center h-32 text-sm text-gray-400">Cargando productos...</div>
|
| 527 |
) : error ? (
|
| 528 |
<div className="flex items-center justify-center h-32 text-sm text-red-400">{error}</div>
|
| 529 |
+
) : searchQuery ? (
|
| 530 |
+
/* BΓΊsqueda activa β lista plana de resultados */
|
| 531 |
+
<div className={viewMode === "grid" ? "px-4" : "px-2"}>
|
| 532 |
+
{filteredProducts.length === 0 ? (
|
| 533 |
+
<div className="flex items-center justify-center h-24 text-sm text-gray-400">Sin resultados</div>
|
| 534 |
+
) : viewMode === "grid" ? (
|
| 535 |
+
<div className="flex flex-col gap-4">
|
| 536 |
+
{chunkArray(filteredProducts, 3).map((group, i) => (
|
| 537 |
+
<ProductGroupCard key={i} group={group} openProductId={openProductId} onSelectProduct={handleProductSelect} />
|
| 538 |
+
))}
|
| 539 |
+
</div>
|
| 540 |
+
) : (
|
| 541 |
+
<div className="grid grid-cols-1 gap-3">
|
| 542 |
+
{filteredProducts.map((product) => (
|
| 543 |
+
<IndividualProductCard key={product.id} product={product} isSelected={openProductId === product.id} onToggle={() => handleProductSelect(product.id)} />
|
| 544 |
+
))}
|
| 545 |
+
</div>
|
| 546 |
+
)}
|
| 547 |
</div>
|
| 548 |
) : (
|
| 549 |
+
/* Sin bΓΊsqueda β acordeΓ³n por categorΓa */
|
| 550 |
+
<div className="flex flex-col">
|
| 551 |
+
{categories.map((cat) => {
|
| 552 |
+
const isOpen = isCategoryOpen(cat.id);
|
| 553 |
+
return (
|
| 554 |
+
<div key={cat.id} className="border-b border-gray-100 last:border-0">
|
| 555 |
+
<button
|
| 556 |
+
onClick={() => toggleCategory(cat.id)}
|
| 557 |
+
className="w-full flex items-center justify-between px-4 py-3 hover:bg-[#f4f8ff] transition-colors text-left"
|
| 558 |
+
>
|
| 559 |
+
<div className="flex items-center gap-2 min-w-0">
|
| 560 |
+
<span className="font-semibold text-sm text-[#333] truncate">{cat.nombre}</span>
|
| 561 |
+
<span className="text-xs text-[#0047AB] bg-[#eaf1ff] px-1.5 py-0.5 rounded-full flex-shrink-0">
|
| 562 |
+
{cat.products.length}
|
| 563 |
+
</span>
|
| 564 |
+
</div>
|
| 565 |
+
<ChevronDown
|
| 566 |
+
className={`w-4 h-4 text-[#0047AB] flex-shrink-0 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
| 567 |
+
/>
|
| 568 |
+
</button>
|
| 569 |
+
|
| 570 |
+
{isOpen && (
|
| 571 |
+
<div className={viewMode === "grid" ? "px-4 pb-4" : "px-2 pb-2"}>
|
| 572 |
+
{viewMode === "grid" ? (
|
| 573 |
+
<div className="flex flex-col gap-4">
|
| 574 |
+
{chunkArray(cat.products, 3).map((group, i) => (
|
| 575 |
+
<ProductGroupCard key={i} group={group} openProductId={openProductId} onSelectProduct={handleProductSelect} />
|
| 576 |
+
))}
|
| 577 |
+
</div>
|
| 578 |
+
) : (
|
| 579 |
+
<div className="grid grid-cols-1 gap-3">
|
| 580 |
+
{cat.products.map((product) => (
|
| 581 |
+
<IndividualProductCard key={product.id} product={product} isSelected={openProductId === product.id} onToggle={() => handleProductSelect(product.id)} />
|
| 582 |
+
))}
|
| 583 |
+
</div>
|
| 584 |
+
)}
|
| 585 |
+
</div>
|
| 586 |
+
)}
|
| 587 |
+
</div>
|
| 588 |
+
);
|
| 589 |
+
})}
|
| 590 |
</div>
|
| 591 |
)}
|
| 592 |
</div>
|
frontend/src/features/roomVisualizer/useCatalogProducts.ts
CHANGED
|
@@ -3,14 +3,6 @@ import type { Product } from "../../types";
|
|
| 3 |
|
| 4 |
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
| 5 |
|
| 6 |
-
const CATEGORY_TYPE: Record<string, "suelos" | "paredes"> = {
|
| 7 |
-
acm: "paredes",
|
| 8 |
-
pisos: "suelos",
|
| 9 |
-
piedra_concreto: "suelos",
|
| 10 |
-
metales: "paredes",
|
| 11 |
-
madera: "suelos",
|
| 12 |
-
};
|
| 13 |
-
|
| 14 |
interface CatalogProduct {
|
| 15 |
id: string;
|
| 16 |
nombre: string;
|
|
@@ -22,6 +14,7 @@ interface CatalogProduct {
|
|
| 22 |
interface CatalogCategory {
|
| 23 |
id: string;
|
| 24 |
nombre: string;
|
|
|
|
| 25 |
descripcion: string;
|
| 26 |
especificaciones?: string[];
|
| 27 |
url_detalle?: string;
|
|
@@ -42,13 +35,23 @@ function mapToProduct(item: CatalogProduct, category: CatalogCategory): Product
|
|
| 42 |
image: `${API_BASE}${item.url_preview}`,
|
| 43 |
description: category.especificaciones,
|
| 44 |
detailUrl: category.url_detalle,
|
| 45 |
-
tipo:
|
| 46 |
categoria: category.id,
|
| 47 |
};
|
| 48 |
}
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
export function useCatalogProducts() {
|
| 51 |
const [products, setProducts] = useState<Product[]>([]);
|
|
|
|
| 52 |
const [loading, setLoading] = useState(true);
|
| 53 |
const [error, setError] = useState<string | null>(null);
|
| 54 |
|
|
@@ -61,12 +64,19 @@ export function useCatalogProducts() {
|
|
| 61 |
if (!res.ok) throw new Error(`Error ${res.status}`);
|
| 62 |
const data: CatalogResponse = await res.json();
|
| 63 |
|
| 64 |
-
const
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
if (!cancelled) {
|
|
|
|
| 70 |
setProducts(flat);
|
| 71 |
setError(null);
|
| 72 |
}
|
|
@@ -83,5 +93,5 @@ export function useCatalogProducts() {
|
|
| 83 |
return () => { cancelled = true; };
|
| 84 |
}, []);
|
| 85 |
|
| 86 |
-
return { products, loading, error };
|
| 87 |
}
|
|
|
|
| 3 |
|
| 4 |
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
interface CatalogProduct {
|
| 7 |
id: string;
|
| 8 |
nombre: string;
|
|
|
|
| 14 |
interface CatalogCategory {
|
| 15 |
id: string;
|
| 16 |
nombre: string;
|
| 17 |
+
tipo?: string;
|
| 18 |
descripcion: string;
|
| 19 |
especificaciones?: string[];
|
| 20 |
url_detalle?: string;
|
|
|
|
| 35 |
image: `${API_BASE}${item.url_preview}`,
|
| 36 |
description: category.especificaciones,
|
| 37 |
detailUrl: category.url_detalle,
|
| 38 |
+
tipo: (category.tipo as "suelos" | "paredes") ?? undefined,
|
| 39 |
categoria: category.id,
|
| 40 |
};
|
| 41 |
}
|
| 42 |
|
| 43 |
+
export interface ProductCategory {
|
| 44 |
+
id: string;
|
| 45 |
+
nombre: string;
|
| 46 |
+
tipo?: string;
|
| 47 |
+
descripcion: string;
|
| 48 |
+
especificaciones?: string[];
|
| 49 |
+
products: Product[];
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
export function useCatalogProducts() {
|
| 53 |
const [products, setProducts] = useState<Product[]>([]);
|
| 54 |
+
const [categories, setCategories] = useState<ProductCategory[]>([]);
|
| 55 |
const [loading, setLoading] = useState(true);
|
| 56 |
const [error, setError] = useState<string | null>(null);
|
| 57 |
|
|
|
|
| 64 |
if (!res.ok) throw new Error(`Error ${res.status}`);
|
| 65 |
const data: CatalogResponse = await res.json();
|
| 66 |
|
| 67 |
+
const cats: ProductCategory[] = data.categories.map((category) => ({
|
| 68 |
+
id: category.id,
|
| 69 |
+
nombre: category.nombre,
|
| 70 |
+
tipo: category.tipo,
|
| 71 |
+
descripcion: category.descripcion,
|
| 72 |
+
especificaciones: category.especificaciones,
|
| 73 |
+
products: category.productos.map((item) => mapToProduct(item, category)),
|
| 74 |
+
}));
|
| 75 |
+
|
| 76 |
+
const flat = cats.flatMap((c) => c.products);
|
| 77 |
|
| 78 |
if (!cancelled) {
|
| 79 |
+
setCategories(cats);
|
| 80 |
setProducts(flat);
|
| 81 |
setError(null);
|
| 82 |
}
|
|
|
|
| 93 |
return () => { cancelled = true; };
|
| 94 |
}, []);
|
| 95 |
|
| 96 |
+
return { products, categories, loading, error };
|
| 97 |
}
|
frontend/src/hooks/useSessionSync.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect } from "react";
|
| 2 |
+
import { useHistoryStore, type HistoryItem } from "../store/useAppStore";
|
| 3 |
+
import { API_BASE } from "../api/client";
|
| 4 |
+
|
| 5 |
+
export async function saveSessionToBackend(
|
| 6 |
+
userId: string,
|
| 7 |
+
item: HistoryItem,
|
| 8 |
+
): Promise<void> {
|
| 9 |
+
try {
|
| 10 |
+
await fetch(`${API_BASE}/api/sessions/${userId}`, {
|
| 11 |
+
method: "POST",
|
| 12 |
+
headers: { "Content-Type": "application/json" },
|
| 13 |
+
body: JSON.stringify(item),
|
| 14 |
+
});
|
| 15 |
+
} catch {
|
| 16 |
+
// fallback silencioso β localStorage es la fuente de verdad local
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export async function deleteSessionFromBackend(
|
| 21 |
+
userId: string,
|
| 22 |
+
filename: string,
|
| 23 |
+
): Promise<void> {
|
| 24 |
+
try {
|
| 25 |
+
await fetch(
|
| 26 |
+
`${API_BASE}/api/sessions/${userId}/${encodeURIComponent(filename)}`,
|
| 27 |
+
{ method: "DELETE" },
|
| 28 |
+
);
|
| 29 |
+
} catch {
|
| 30 |
+
// fallback silencioso
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function useLoadSessionHistory(): void {
|
| 35 |
+
const userId = useHistoryStore((s) => s.userId);
|
| 36 |
+
const localHistory = useHistoryStore((s) => s.sessionHistory);
|
| 37 |
+
const setSessionHistory = useHistoryStore((s) => s.setSessionHistory);
|
| 38 |
+
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
async function load() {
|
| 41 |
+
try {
|
| 42 |
+
const res = await fetch(`${API_BASE}/api/sessions/${userId}`);
|
| 43 |
+
if (!res.ok) return;
|
| 44 |
+
const data = await res.json();
|
| 45 |
+
const remote: HistoryItem[] = data.items ?? [];
|
| 46 |
+
if (remote.length === 0) return;
|
| 47 |
+
|
| 48 |
+
// Merge remoto + local: dedupe por filename, mΓ‘s reciente gana
|
| 49 |
+
const merged = [...remote, ...localHistory];
|
| 50 |
+
const seen = new Set<string>();
|
| 51 |
+
const deduped = merged.filter((item) => {
|
| 52 |
+
if (seen.has(item.filename)) return false;
|
| 53 |
+
seen.add(item.filename);
|
| 54 |
+
return true;
|
| 55 |
+
});
|
| 56 |
+
deduped.sort((a, b) => b.uploadedAt - a.uploadedAt);
|
| 57 |
+
setSessionHistory(deduped.slice(0, 10));
|
| 58 |
+
} catch {
|
| 59 |
+
// red no disponible β se mantiene historial local
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
load();
|
| 63 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 64 |
+
}, [userId]);
|
| 65 |
+
}
|
frontend/src/store/useAppStore.ts
CHANGED
|
@@ -3,6 +3,63 @@ import { persist, createJSONStorage } from "zustand/middleware";
|
|
| 3 |
|
| 4 |
type ViewMode = "grid" | "list";
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
type AppStore = {
|
| 7 |
previewImage: string | null;
|
| 8 |
uploadMessage: string | null;
|
|
@@ -56,7 +113,6 @@ const useAppStore = create<AppStore>()(
|
|
| 56 |
{
|
| 57 |
name: "hr-session",
|
| 58 |
storage: createJSONStorage(() => sessionStorage),
|
| 59 |
-
// Only persist the fields needed to restore the editing session
|
| 60 |
partialize: (state) => ({
|
| 61 |
previewImage: state.previewImage,
|
| 62 |
segmentFilename: state.segmentFilename,
|
|
|
|
| 3 |
|
| 4 |
type ViewMode = "grid" | "list";
|
| 5 |
|
| 6 |
+
export type HistoryItem = {
|
| 7 |
+
filename: string;
|
| 8 |
+
previewUrl: string;
|
| 9 |
+
maskCount: number;
|
| 10 |
+
uploadedAt: number;
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
// ββ userId persistente en localStorage βββββββββββββββββββββββββββββββββββββββ
|
| 14 |
+
function getOrCreateUserId(): string {
|
| 15 |
+
const key = "hr-user-id";
|
| 16 |
+
let id = localStorage.getItem(key);
|
| 17 |
+
if (!id) {
|
| 18 |
+
id = typeof crypto !== "undefined" && crypto.randomUUID
|
| 19 |
+
? crypto.randomUUID()
|
| 20 |
+
: `u-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
| 21 |
+
localStorage.setItem(key, id);
|
| 22 |
+
}
|
| 23 |
+
return id;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// ββ Store de historial (localStorage β persiste entre sesiones) βββββββββββββββ
|
| 27 |
+
type HistoryStore = {
|
| 28 |
+
userId: string;
|
| 29 |
+
sessionHistory: HistoryItem[];
|
| 30 |
+
addToHistory: (item: HistoryItem) => void;
|
| 31 |
+
setSessionHistory: (items: HistoryItem[]) => void;
|
| 32 |
+
removeFromHistory: (filename: string) => void;
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
export const useHistoryStore = create<HistoryStore>()(
|
| 36 |
+
persist(
|
| 37 |
+
(set) => ({
|
| 38 |
+
userId: getOrCreateUserId(),
|
| 39 |
+
sessionHistory: [],
|
| 40 |
+
addToHistory: (item) =>
|
| 41 |
+
set((state) => {
|
| 42 |
+
const withoutDupe = state.sessionHistory.filter(
|
| 43 |
+
(h) => h.filename !== item.filename,
|
| 44 |
+
);
|
| 45 |
+
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
| 46 |
+
const fresh = withoutDupe.filter((h) => h.uploadedAt > cutoff);
|
| 47 |
+
return { sessionHistory: [item, ...fresh].slice(0, 10) };
|
| 48 |
+
}),
|
| 49 |
+
setSessionHistory: (items) => set({ sessionHistory: items }),
|
| 50 |
+
removeFromHistory: (filename) =>
|
| 51 |
+
set((state) => ({
|
| 52 |
+
sessionHistory: state.sessionHistory.filter((h) => h.filename !== filename),
|
| 53 |
+
})),
|
| 54 |
+
}),
|
| 55 |
+
{
|
| 56 |
+
name: "hr-history",
|
| 57 |
+
storage: createJSONStorage(() => localStorage),
|
| 58 |
+
},
|
| 59 |
+
),
|
| 60 |
+
);
|
| 61 |
+
|
| 62 |
+
// ββ Store de sesiΓ³n activa (sessionStorage β se limpia al cerrar el tab) ββββββ
|
| 63 |
type AppStore = {
|
| 64 |
previewImage: string | null;
|
| 65 |
uploadMessage: string | null;
|
|
|
|
| 113 |
{
|
| 114 |
name: "hr-session",
|
| 115 |
storage: createJSONStorage(() => sessionStorage),
|
|
|
|
| 116 |
partialize: (state) => ({
|
| 117 |
previewImage: state.previewImage,
|
| 118 |
segmentFilename: state.segmentFilename,
|
frontend/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
export const appVersion = "0.1.0-dev.
|
|
|
|
| 1 |
+
export const appVersion = "0.1.0-dev.20260507T183851";
|