diff --git a/apps/captain-mobile/captain-api.service b/apps/captain-mobile/captain-api.service new file mode 100644 index 0000000..431b385 --- /dev/null +++ b/apps/captain-mobile/captain-api.service @@ -0,0 +1,20 @@ +[Unit] +Description=Captain Claude Mobile API +After=network.target postgresql.service + +[Service] +Type=simple +User=architect +WorkingDirectory=/home/architect/captain-claude/apps/captain-mobile +Environment="PATH=/home/architect/.local/bin:/usr/local/bin:/usr/bin:/bin" +Environment="JWT_SECRET=captain-claude-mobile-prod-secret-2025" +Environment="API_USER=captain" +Environment="API_PASSWORD=tzzr2025" +Environment="DATABASE_URL=postgresql://captain:captain@localhost/captain_mobile" +Environment="CLAUDE_CMD=/home/architect/.claude/local/claude" +ExecStart=/home/architect/captain-claude/apps/captain-mobile/venv/bin/uvicorn captain_api:app --host 127.0.0.1 --port 3030 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/apps/captain-mobile/captain_api.py b/apps/captain-mobile/captain_api.py new file mode 100644 index 0000000..901aa01 --- /dev/null +++ b/apps/captain-mobile/captain_api.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +""" +Captain Claude Mobile API +FastAPI backend for Captain Claude mobile app +Provides REST + WebSocket endpoints for chat and terminal access +""" + +import asyncio +import json +import os +import pty +import select +import subprocess +import uuid +from datetime import datetime, timedelta +from typing import Optional + +import asyncpg +import jwt +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, UploadFile, File +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel +from contextlib import asynccontextmanager + +# Configuration +JWT_SECRET = os.getenv("JWT_SECRET", "captain-claude-secret-key-change-in-production") +JWT_ALGORITHM = "HS256" +JWT_EXPIRATION_DAYS = 7 +API_USER = os.getenv("API_USER", "captain") +API_PASSWORD = os.getenv("API_PASSWORD", "tzzr2025") +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://captain:captain@localhost/captain_mobile") +CLAUDE_CMD = os.getenv("CLAUDE_CMD", "/home/architect/.claude/local/claude") + +# Database pool +db_pool: Optional[asyncpg.Pool] = None + +@asynccontextmanager +async def lifespan(app: FastAPI): + global db_pool + try: + db_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10) + await init_database() + print("Database connected") + except Exception as e: + print(f"Database connection failed: {e}") + db_pool = None + yield + if db_pool: + await db_pool.close() + +app = FastAPI( + title="Captain Claude Mobile API", + version="1.0.0", + lifespan=lifespan +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +security = HTTPBearer() + +# Models +class LoginRequest(BaseModel): + username: str + password: str + +class LoginResponse(BaseModel): + token: str + expires_at: str + +class Message(BaseModel): + role: str + content: str + timestamp: str + +class Conversation(BaseModel): + id: str + title: str + created_at: str + message_count: int + +class ScreenSession(BaseModel): + name: str + pid: str + attached: bool + +# Database initialization +async def init_database(): + if not db_pool: + return + async with db_pool.acquire() as conn: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(255) NOT NULL, + title VARCHAR(500), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + """) + await conn.execute(""" + CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id) + """) + +# Auth helpers +def create_token(username: str) -> tuple[str, datetime]: + expires = datetime.utcnow() + timedelta(days=JWT_EXPIRATION_DAYS) + token = jwt.encode( + {"sub": username, "exp": expires}, + JWT_SECRET, + algorithm=JWT_ALGORITHM + ) + return token, expires + +def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: + try: + payload = jwt.decode(credentials.credentials, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return payload["sub"] + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") + +# REST Endpoints +@app.get("/health") +async def health(): + return {"status": "ok", "service": "captain-api", "version": "1.0.0"} + +@app.post("/auth/login", response_model=LoginResponse) +async def login(request: LoginRequest): + if request.username == API_USER and request.password == API_PASSWORD: + token, expires = create_token(request.username) + return LoginResponse(token=token, expires_at=expires.isoformat()) + raise HTTPException(status_code=401, detail="Invalid credentials") + +@app.get("/sessions") +async def list_sessions(user: str = Depends(verify_token)) -> list[ScreenSession]: + """List active screen sessions""" + try: + result = subprocess.run( + ["screen", "-ls"], + capture_output=True, + text=True + ) + sessions = [] + for line in result.stdout.split("\n"): + if "\t" in line and ("Attached" in line or "Detached" in line): + parts = line.strip().split("\t") + if len(parts) >= 2: + session_info = parts[0] + pid_name = session_info.split(".") + if len(pid_name) >= 2: + sessions.append(ScreenSession( + pid=pid_name[0], + name=".".join(pid_name[1:]), + attached="Attached" in line + )) + return sessions + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/history") +async def get_history(user: str = Depends(verify_token), limit: int = 20) -> list[Conversation]: + """Get conversation history""" + if not db_pool: + return [] + async with db_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT c.id, c.title, c.created_at, + COUNT(m.id) as message_count + FROM conversations c + LEFT JOIN messages m ON m.conversation_id = c.id + WHERE c.user_id = $1 + GROUP BY c.id + ORDER BY c.updated_at DESC + LIMIT $2 + """, user, limit) + return [ + Conversation( + id=str(row["id"]), + title=row["title"] or "Untitled", + created_at=row["created_at"].isoformat(), + message_count=row["message_count"] + ) + for row in rows + ] + +@app.get("/history/{conversation_id}") +async def get_conversation(conversation_id: str, user: str = Depends(verify_token)) -> list[Message]: + """Get messages for a conversation""" + if not db_pool: + return [] + async with db_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT m.role, m.content, m.created_at + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + WHERE c.id = $1 AND c.user_id = $2 + ORDER BY m.created_at ASC + """, uuid.UUID(conversation_id), user) + return [ + Message( + role=row["role"], + content=row["content"], + timestamp=row["created_at"].isoformat() + ) + for row in rows + ] + +@app.post("/upload") +async def upload_file(file: UploadFile = File(...), user: str = Depends(verify_token)): + """Upload a file for context""" + upload_dir = "/tmp/captain-uploads" + os.makedirs(upload_dir, exist_ok=True) + + file_id = str(uuid.uuid4()) + file_path = os.path.join(upload_dir, f"{file_id}_{file.filename}") + + with open(file_path, "wb") as f: + content = await file.read() + f.write(content) + + return {"file_id": file_id, "filename": file.filename, "path": file_path} + +# WebSocket Chat with Captain Claude +@app.websocket("/ws/chat") +async def websocket_chat(websocket: WebSocket): + await websocket.accept() + + # First message should be auth token + try: + auth_data = await asyncio.wait_for(websocket.receive_json(), timeout=10) + token = auth_data.get("token") + if not token: + await websocket.close(code=4001, reason="No token provided") + return + + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + user = payload["sub"] + except: + await websocket.close(code=4001, reason="Invalid token") + return + + await websocket.send_json({"type": "connected", "user": user}) + + # Create conversation + conversation_id = None + if db_pool: + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING id", + user, "New conversation" + ) + conversation_id = row["id"] + + # Main chat loop + while True: + data = await websocket.receive_json() + + if data.get("type") == "message": + user_message = data.get("content", "") + context_files = data.get("files", []) + + # Save user message + if db_pool and conversation_id: + async with db_pool.acquire() as conn: + await conn.execute( + "INSERT INTO messages (conversation_id, role, content) VALUES ($1, $2, $3)", + conversation_id, "user", user_message + ) + + # Build claude command + cmd = [CLAUDE_CMD, "-p", user_message, "--output-format", "stream-json"] + + # Add file context if provided + for file_path in context_files: + if os.path.exists(file_path): + cmd.extend(["--file", file_path]) + + # Stream response from claude + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd="/home/architect/captain-claude" + ) + + full_response = "" + + async for line in process.stdout: + line = line.decode().strip() + if not line: + continue + + try: + event = json.loads(line) + event_type = event.get("type") + + if event_type == "assistant": + # Start of response + await websocket.send_json({ + "type": "start", + "conversation_id": str(conversation_id) if conversation_id else None + }) + + elif event_type == "content_block_delta": + delta = event.get("delta", {}) + if delta.get("type") == "text_delta": + text = delta.get("text", "") + full_response += text + await websocket.send_json({ + "type": "delta", + "content": text + }) + + elif event_type == "result": + # End of response + await websocket.send_json({ + "type": "done", + "content": full_response + }) + + except json.JSONDecodeError: + continue + + await process.wait() + + # Save assistant message + if db_pool and conversation_id and full_response: + async with db_pool.acquire() as conn: + await conn.execute( + "INSERT INTO messages (conversation_id, role, content) VALUES ($1, $2, $3)", + conversation_id, "assistant", full_response + ) + # Update title from first response + title = full_response[:100].split("\n")[0] + await conn.execute( + "UPDATE conversations SET title = $1, updated_at = NOW() WHERE id = $2", + title, conversation_id + ) + + except Exception as e: + await websocket.send_json({ + "type": "error", + "message": str(e) + }) + + elif data.get("type") == "ping": + await websocket.send_json({"type": "pong"}) + + except WebSocketDisconnect: + pass + except asyncio.TimeoutError: + await websocket.close(code=4002, reason="Auth timeout") + except Exception as e: + print(f"Chat WebSocket error: {e}") + try: + await websocket.close(code=4000, reason=str(e)) + except: + pass + +# WebSocket Terminal for screen sessions +@app.websocket("/ws/terminal/{session_name}") +async def websocket_terminal(websocket: WebSocket, session_name: str): + await websocket.accept() + + # Auth + try: + auth_data = await asyncio.wait_for(websocket.receive_json(), timeout=10) + token = auth_data.get("token") + if not token: + await websocket.close(code=4001, reason="No token provided") + return + + try: + jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except: + await websocket.close(code=4001, reason="Invalid token") + return + + await websocket.send_json({"type": "connected", "session": session_name}) + + # Create PTY and attach to screen session + master_fd, slave_fd = pty.openpty() + + process = subprocess.Popen( + ["screen", "-x", session_name], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + preexec_fn=os.setsid + ) + + os.close(slave_fd) + + # Set non-blocking + import fcntl + flags = fcntl.fcntl(master_fd, fcntl.F_GETFL) + fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + async def read_pty(): + """Read from PTY and send to websocket""" + while True: + try: + await asyncio.sleep(0.01) + r, _, _ = select.select([master_fd], [], [], 0) + if r: + data = os.read(master_fd, 4096) + if data: + await websocket.send_json({ + "type": "output", + "data": data.decode("utf-8", errors="replace") + }) + except Exception as e: + break + + async def write_pty(): + """Read from websocket and write to PTY""" + while True: + try: + data = await websocket.receive_json() + if data.get("type") == "input": + os.write(master_fd, data.get("data", "").encode()) + elif data.get("type") == "resize": + import struct + import fcntl + import termios + winsize = struct.pack("HHHH", + data.get("rows", 24), + data.get("cols", 80), + 0, 0 + ) + fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize) + except WebSocketDisconnect: + break + except Exception as e: + break + + # Run both tasks + read_task = asyncio.create_task(read_pty()) + write_task = asyncio.create_task(write_pty()) + + try: + await asyncio.gather(read_task, write_task) + finally: + read_task.cancel() + write_task.cancel() + os.close(master_fd) + process.terminate() + + except asyncio.TimeoutError: + await websocket.close(code=4002, reason="Auth timeout") + except Exception as e: + print(f"Terminal WebSocket error: {e}") + try: + await websocket.close(code=4000, reason=str(e)) + except: + pass + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=3030) diff --git a/apps/captain-mobile/requirements.txt b/apps/captain-mobile/requirements.txt new file mode 100644 index 0000000..e4ed30f --- /dev/null +++ b/apps/captain-mobile/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +asyncpg==0.29.0 +pyjwt==2.9.0 +python-multipart==0.0.9 +websockets==13.0 diff --git a/apps/captain-mobile/schema.sql b/apps/captain-mobile/schema.sql new file mode 100644 index 0000000..28485bf --- /dev/null +++ b/apps/captain-mobile/schema.sql @@ -0,0 +1,42 @@ +-- Captain Claude Mobile - PostgreSQL Schema +-- Database: captain_mobile + +-- Create user and database if not exists (run as postgres superuser) +-- CREATE USER captain WITH PASSWORD 'captain'; +-- CREATE DATABASE captain_mobile OWNER captain; +-- GRANT ALL PRIVILEGES ON DATABASE captain_mobile TO captain; + +-- Tables for conversation history +CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(255) NOT NULL, + title VARCHAR(500), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL, -- 'user' or 'assistant' + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id); +CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id); +CREATE INDEX IF NOT EXISTS idx_conversations_updated ON conversations(updated_at DESC); + +-- File attachments tracking (optional) +CREATE TABLE IF NOT EXISTS attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID REFERENCES messages(id) ON DELETE CASCADE, + filename VARCHAR(500) NOT NULL, + file_path VARCHAR(1000) NOT NULL, + mime_type VARCHAR(100), + size_bytes BIGINT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id); diff --git a/apps/flow-ui b/apps/flow-ui new file mode 160000 index 0000000..f0c09b1 --- /dev/null +++ b/apps/flow-ui @@ -0,0 +1 @@ +Subproject commit f0c09b10ad03018cbff0f3412098774234e98bf3 diff --git a/apps/mindlink b/apps/mindlink new file mode 160000 index 0000000..40c0944 --- /dev/null +++ b/apps/mindlink @@ -0,0 +1 @@ +Subproject commit 40c0944cf7513920a14d370831b298f9e3f8ca1a diff --git a/apps/storage/migrate_atc.py b/apps/storage/migrate_atc.py new file mode 100644 index 0000000..d19991a --- /dev/null +++ b/apps/storage/migrate_atc.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +Migración: Importar archivos existentes de secretaria_clara.atc a storage +""" + +import asyncio +import asyncpg +import os +import json + +DB_URL = os.environ.get("DATABASE_URL", "postgresql://tzzr:tzzr@localhost:5432/tzzr") + + +async def migrate(): + pool = await asyncpg.create_pool(DB_URL, min_size=2, max_size=10) + + async with pool.acquire() as conn: + # Obtener archivos de atc que tienen hash y url_file + atc_files = await conn.fetch(""" + SELECT + mrf, + private_mrf, + alias, + name_es, + ref, + ext, + jsonb_standard, + hashtags + FROM secretaria_clara.atc + WHERE jsonb_standard IS NOT NULL + AND jsonb_standard->'L2_document'->>'url_file' IS NOT NULL + """) + + print(f"Encontrados {len(atc_files)} archivos en atc") + + migrated = 0 + skipped = 0 + errors = 0 + + for file in atc_files: + try: + mrf = file["mrf"] + jsonb = file["jsonb_standard"] or {} + + # Extraer datos + l2 = jsonb.get("L2_document", {}) + url_file = l2.get("url_file") + size_bytes = l2.get("size_bytes", 0) + mime_type = l2.get("mime_type", "application/octet-stream") + + if not url_file: + skipped += 1 + continue + + # Verificar si ya existe en storage + existing = await conn.fetchrow(""" + SELECT content_hash FROM storage.physical_blobs + WHERE content_hash = $1 + """, mrf) + + if existing: + skipped += 1 + continue + + # Insertar en physical_blobs + await conn.execute(""" + INSERT INTO storage.physical_blobs + (content_hash, file_size, mime_type, storage_provider, storage_path, verification_status) + VALUES ($1, $2, $3, 'R2_PRIMARY', $4, 'VERIFIED') + """, mrf, size_bytes, mime_type, url_file) + + # Crear user_asset con el mismo public_key que el mrf + # Usamos un UUID dummy para user_id ya que no tenemos usuarios + await conn.execute(""" + INSERT INTO storage.user_assets + (public_key, blob_hash, user_id, original_filename) + VALUES ($1, $2, '00000000-0000-0000-0000-000000000000'::uuid, $3) + ON CONFLICT (public_key) DO NOTHING + """, mrf, mrf, file["name_es"] or file["alias"] or mrf[:20]) + + migrated += 1 + + if migrated % 100 == 0: + print(f" Migrados: {migrated}") + + except Exception as e: + errors += 1 + print(f"Error migrando {file['mrf']}: {e}") + + print(f"\nMigración completada:") + print(f" - Migrados: {migrated}") + print(f" - Saltados (ya existían o sin datos): {skipped}") + print(f" - Errores: {errors}") + + # Actualizar ref_count + await conn.execute(""" + UPDATE storage.physical_blobs pb + SET ref_count = ( + SELECT COUNT(*) FROM storage.user_assets ua + WHERE ua.blob_hash = pb.content_hash + ) + """) + print(" - ref_count actualizado") + + await pool.close() + + +if __name__ == "__main__": + asyncio.run(migrate()) diff --git a/apps/storage/requirements.txt b/apps/storage/requirements.txt new file mode 100644 index 0000000..8a3832d --- /dev/null +++ b/apps/storage/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.104.0 +uvicorn>=0.24.0 +asyncpg>=0.29.0 +boto3>=1.34.0 +Pillow>=10.0.0 +PyMuPDF>=1.23.0 +argon2-cffi>=23.1.0 +python-multipart>=0.0.6 +pydantic>=2.5.0 diff --git a/apps/storage/storage-api.service b/apps/storage/storage-api.service new file mode 100644 index 0000000..4452dd8 --- /dev/null +++ b/apps/storage/storage-api.service @@ -0,0 +1,20 @@ +[Unit] +Description=Storage API Server +After=network.target postgresql.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/storage +ExecStart=/opt/storage/venv/bin/python storage_api.py +Restart=always +RestartSec=5 + +Environment=R2_ENDPOINT=https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com +Environment=R2_BUCKET=deck +Environment=DATABASE_URL=postgresql://tzzr:tzzr@localhost:5432/tzzr +Environment=AWS_ACCESS_KEY_ID= +Environment=AWS_SECRET_ACCESS_KEY= + +[Install] +WantedBy=multi-user.target diff --git a/apps/storage/storage_api.py b/apps/storage/storage_api.py new file mode 100644 index 0000000..c64e7c6 --- /dev/null +++ b/apps/storage/storage_api.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +""" +Storage API - Endpoints para upload/download de archivos +Spec: Sistema de Almacenamiento Híbrido v4.0 +""" + +import os +import hashlib +import json +import asyncio +from datetime import datetime, timedelta +from typing import Optional +import asyncpg +import boto3 +from fastapi import FastAPI, HTTPException, Request, Header, Query, BackgroundTasks +from fastapi.responses import RedirectResponse, JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import uvicorn +from collections import defaultdict +import time +import argon2 + +# Configuración +R2_ENDPOINT = os.environ.get("R2_ENDPOINT", "https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com") +R2_BUCKET = os.environ.get("R2_BUCKET", "deck") +DB_URL = os.environ.get("DATABASE_URL", "postgresql://tzzr:tzzr@localhost:5432/tzzr") + +PRESIGNED_UPLOAD_EXPIRY = 3 * 60 * 60 # 3 horas +PRESIGNED_DOWNLOAD_EXPIRY = 45 * 60 # 45 minutos + +# Rate limiting +RATE_LIMIT_IP = 100 # req/min por IP +RATE_LIMIT_KEY = 50 # descargas/hora por public_key +RATE_LIMIT_TRANSFER = 10 * 1024 * 1024 * 1024 # 10 GB/hora + +app = FastAPI(title="Storage API", version="4.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# Estado global +db_pool = None +s3_client = None +rate_limits = { + "ip": defaultdict(list), # IP -> [timestamps] + "key": defaultdict(list), # public_key -> [timestamps] + "transfer": defaultdict(int) # IP -> bytes +} + +ph = argon2.PasswordHasher() + + +# ========================================================================= +# STARTUP / SHUTDOWN +# ========================================================================= + +@app.on_event("startup") +async def startup(): + global db_pool, s3_client + db_pool = await asyncpg.create_pool(DB_URL, min_size=2, max_size=20) + s3_client = boto3.client( + "s3", + endpoint_url=R2_ENDPOINT, + aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), + aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), + ) + + +@app.on_event("shutdown") +async def shutdown(): + if db_pool: + await db_pool.close() + + +# ========================================================================= +# RATE LIMITING +# ========================================================================= + +def check_rate_limit_ip(ip: str) -> bool: + """100 req/min por IP""" + now = time.time() + minute_ago = now - 60 + + # Limpiar timestamps viejos + rate_limits["ip"][ip] = [t for t in rate_limits["ip"][ip] if t > minute_ago] + + if len(rate_limits["ip"][ip]) >= RATE_LIMIT_IP: + return False + + rate_limits["ip"][ip].append(now) + return True + + +def check_rate_limit_key(public_key: str) -> bool: + """50 descargas/hora por public_key""" + now = time.time() + hour_ago = now - 3600 + + rate_limits["key"][public_key] = [t for t in rate_limits["key"][public_key] if t > hour_ago] + + if len(rate_limits["key"][public_key]) >= RATE_LIMIT_KEY: + return False + + rate_limits["key"][public_key].append(now) + return True + + +# ========================================================================= +# MODELS +# ========================================================================= + +class UploadInitRequest(BaseModel): + hash: str + size: int + mime_type: str + filename: str + user_id: str + password: Optional[str] = None + + +class UploadInitResponse(BaseModel): + status: str + presigned_url: Optional[str] = None + deduplicated: bool = False + public_key: Optional[str] = None + + +# ========================================================================= +# UPLOAD ENDPOINTS +# ========================================================================= + +@app.post("/upload/init", response_model=UploadInitResponse) +async def upload_init(req: UploadInitRequest, request: Request, background_tasks: BackgroundTasks): + """ + Iniciar upload. Devuelve presigned URL o confirma deduplicación. + """ + client_ip = request.client.host + + if not check_rate_limit_ip(client_ip): + raise HTTPException(429, "Rate limit exceeded") + + async with db_pool.acquire() as conn: + # Verificar si blob ya existe + blob = await conn.fetchrow(""" + SELECT content_hash, verification_status + FROM storage.physical_blobs + WHERE content_hash = $1 + """, req.hash) + + if blob: + if blob["verification_status"] == "VERIFIED": + # Deduplicación: crear asset sin subir + public_key = hashlib.sha256( + f"{req.hash}{req.user_id}{datetime.now().isoformat()}".encode() + ).hexdigest() + + password_hash = None + if req.password: + password_hash = ph.hash(req.password) + + await conn.execute(""" + INSERT INTO storage.user_assets + (public_key, blob_hash, user_id, original_filename, access_password) + VALUES ($1, $2, $3, $4, $5) + """, public_key, req.hash, req.user_id, req.filename, password_hash) + + return UploadInitResponse( + status="created", + deduplicated=True, + public_key=public_key + ) + + # Blob existe pero PENDING - cliente debe subir de todas formas + + else: + # Crear registro PENDING + storage_path = f"{req.hash}.bin" + await conn.execute(""" + INSERT INTO storage.physical_blobs + (content_hash, file_size, mime_type, storage_provider, storage_path) + VALUES ($1, $2, $3, 'R2_PRIMARY', $4) + """, req.hash, req.size, req.mime_type, storage_path) + + # Generar presigned URL para upload + storage_path = f"{req.hash}.bin" + presigned_url = s3_client.generate_presigned_url( + "put_object", + Params={ + "Bucket": R2_BUCKET, + "Key": storage_path, + "ContentType": req.mime_type + }, + ExpiresIn=PRESIGNED_UPLOAD_EXPIRY + ) + + return UploadInitResponse( + status="upload_required", + presigned_url=presigned_url, + deduplicated=False + ) + + +@app.post("/upload/complete/{content_hash}") +async def upload_complete( + content_hash: str, + user_id: str = Query(...), + filename: str = Query(...), + password: Optional[str] = Query(None), + background_tasks: BackgroundTasks = None +): + """ + Confirmar upload completado. Encola verificación. + """ + async with db_pool.acquire() as conn: + blob = await conn.fetchrow(""" + SELECT content_hash, storage_path + FROM storage.physical_blobs + WHERE content_hash = $1 + """, content_hash) + + if not blob: + raise HTTPException(404, "Blob not found") + + # Encolar verificación en background + # En producción esto iría a una cola (Redis, RabbitMQ, etc.) + background_tasks.add_task( + verify_and_finalize, + content_hash, + blob["storage_path"], + user_id, + filename, + password + ) + + return {"status": "processing", "content_hash": content_hash} + + +async def verify_and_finalize( + content_hash: str, + storage_path: str, + user_id: str, + filename: str, + password: Optional[str] +): + """Background task para verificar y finalizar upload""" + from storage_worker import StorageWorker + + worker = StorageWorker() + await worker.init() + + try: + result = await worker.process_upload( + content_hash, + storage_path, + user_id, + filename, + ph.hash(password) if password else None + ) + # En producción: notificar cliente via webhook/websocket + print(f"Upload finalized: {result}") + finally: + await worker.close() + + +# ========================================================================= +# DOWNLOAD ENDPOINTS +# ========================================================================= + +@app.get("/file/{public_key}") +async def download_file( + public_key: str, + request: Request, + password: Optional[str] = Query(None) +): + """ + Descarga de archivo. Devuelve redirect a URL firmada. + """ + client_ip = request.client.host + + # Rate limiting + if not check_rate_limit_ip(client_ip): + raise HTTPException(429, "Rate limit exceeded - IP") + + if not check_rate_limit_key(public_key): + raise HTTPException(429, "Rate limit exceeded - downloads") + + async with db_pool.acquire() as conn: + # Buscar asset + asset = await conn.fetchrow(""" + SELECT a.id, a.blob_hash, a.original_filename, a.access_password, a.downloads_count, + b.storage_provider, b.storage_path, b.verification_status, b.mime_type + FROM storage.user_assets a + JOIN storage.physical_blobs b ON a.blob_hash = b.content_hash + WHERE a.public_key = $1 + """, public_key) + + if not asset: + raise HTTPException(404, "Asset not found") + + # Verificar contraseña si requerida + if asset["access_password"]: + if not password: + raise HTTPException(401, "Password required") + try: + ph.verify(asset["access_password"], password) + except: + raise HTTPException(401, "Invalid password") + + # Verificar estado del blob + status = asset["verification_status"] + + if status == "PENDING": + raise HTTPException(202, "File is being processed") + + if status in ("CORRUPT", "LOST"): + raise HTTPException(410, "File is no longer available") + + # Incrementar contador de descargas + await conn.execute(""" + UPDATE storage.user_assets + SET downloads_count = downloads_count + 1 + WHERE id = $1 + """, asset["id"]) + + # Generar URL firmada según provider + provider = asset["storage_provider"] + + if provider in ("R2_PRIMARY", "R2_CACHE"): + presigned_url = s3_client.generate_presigned_url( + "get_object", + Params={ + "Bucket": R2_BUCKET, + "Key": asset["storage_path"], + "ResponseContentDisposition": f'attachment; filename="{asset["original_filename"]}"', + "ResponseContentType": asset["mime_type"] + }, + ExpiresIn=PRESIGNED_DOWNLOAD_EXPIRY + ) + return RedirectResponse(presigned_url, status_code=302) + + elif provider == "SHAREPOINT": + # TODO: Implementar acceso SharePoint via Graph API + raise HTTPException(503, "SharePoint access not implemented") + + else: + raise HTTPException(503, "Unknown storage provider") + + +@app.get("/file/{public_key}/info") +async def file_info(public_key: str, request: Request): + """ + Información del archivo sin descargarlo. + """ + client_ip = request.client.host + + if not check_rate_limit_ip(client_ip): + raise HTTPException(429, "Rate limit exceeded") + + async with db_pool.acquire() as conn: + asset = await conn.fetchrow(""" + SELECT a.public_key, a.original_filename, a.downloads_count, a.created_at, + b.file_size, b.mime_type, b.verification_status, + (a.access_password IS NOT NULL) as password_protected + FROM storage.user_assets a + JOIN storage.physical_blobs b ON a.blob_hash = b.content_hash + WHERE a.public_key = $1 + """, public_key) + + if not asset: + raise HTTPException(404, "Asset not found") + + return { + "public_key": asset["public_key"], + "filename": asset["original_filename"], + "size": asset["file_size"], + "mime_type": asset["mime_type"], + "status": asset["verification_status"], + "downloads": asset["downloads_count"], + "password_protected": asset["password_protected"], + "created_at": asset["created_at"].isoformat() + } + + +@app.get("/file/{public_key}/thumb") +async def file_thumbnail(public_key: str, request: Request): + """ + Redirect al thumbnail del archivo. + """ + client_ip = request.client.host + + if not check_rate_limit_ip(client_ip): + raise HTTPException(429, "Rate limit exceeded") + + async with db_pool.acquire() as conn: + asset = await conn.fetchrow(""" + SELECT a.blob_hash, b.verification_status + FROM storage.user_assets a + JOIN storage.physical_blobs b ON a.blob_hash = b.content_hash + WHERE a.public_key = $1 + """, public_key) + + if not asset: + raise HTTPException(404, "Asset not found") + + if asset["verification_status"] != "VERIFIED": + raise HTTPException(202, "Thumbnail not ready") + + # URL al thumbnail + thumb_key = f"{asset['blob_hash']}.thumb" + + try: + # Verificar que existe + s3_client.head_object(Bucket=R2_BUCKET, Key=thumb_key) + except: + raise HTTPException(404, "Thumbnail not available") + + presigned_url = s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": R2_BUCKET, "Key": thumb_key}, + ExpiresIn=PRESIGNED_DOWNLOAD_EXPIRY + ) + + return RedirectResponse(presigned_url, status_code=302) + + +# ========================================================================= +# HEALTH +# ========================================================================= + +@app.get("/health") +async def health(): + return {"status": "ok", "timestamp": datetime.now().isoformat()} + + +# ========================================================================= +# MAIN +# ========================================================================= + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/apps/storage/storage_worker.py b/apps/storage/storage_worker.py new file mode 100644 index 0000000..bc51283 --- /dev/null +++ b/apps/storage/storage_worker.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +""" +Storage Worker - Verificación y procesamiento de archivos +Spec: Sistema de Almacenamiento Híbrido v4.0 +""" + +import os +import hashlib +import json +import asyncio +import asyncpg +from datetime import datetime +from typing import Optional, Dict, Any +import boto3 +from PIL import Image +import fitz # PyMuPDF +import io +import tempfile + +# Configuración +R2_ENDPOINT = os.environ.get("R2_ENDPOINT", "https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com") +R2_BUCKET = os.environ.get("R2_BUCKET", "deck") +DB_URL = os.environ.get("DATABASE_URL", "postgresql://tzzr:tzzr@localhost:5432/tzzr") + +THUMB_WIDTH = 300 +MAX_RETRIES = 9 +RETRY_BACKOFF_BASE = 2 + + +def get_s3_client(): + return boto3.client( + "s3", + endpoint_url=R2_ENDPOINT, + aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), + aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), + ) + + +async def get_db_pool(): + return await asyncpg.create_pool(DB_URL, min_size=2, max_size=10) + + +def calculate_sha256(data: bytes) -> str: + """Calcula SHA-256 de bytes""" + return hashlib.sha256(data).hexdigest() + + +def generate_public_key(content_hash: str, user_id: str) -> str: + """Genera public_key única para un asset""" + data = f"{content_hash}{user_id}{datetime.now().isoformat()}" + return hashlib.sha256(data.encode()).hexdigest() + + +class StorageWorker: + def __init__(self): + self.s3 = get_s3_client() + self.pool = None + + async def init(self): + self.pool = await get_db_pool() + + async def close(self): + if self.pool: + await self.pool.close() + + # ========================================================================= + # VERIFICACIÓN DE HASH + # ========================================================================= + + async def verify_blob(self, declared_hash: str, storage_path: str) -> Dict[str, Any]: + """ + Verifica que el hash declarado coincida con el contenido real. + NUNCA confiamos en el hash del cliente. + """ + try: + # Descargar archivo + obj = self.s3.get_object(Bucket=R2_BUCKET, Key=storage_path) + content = obj["Body"].read() + + # Calcular hash real + calculated_hash = calculate_sha256(content) + + if calculated_hash != declared_hash: + # HASH MISMATCH - Archivo corrupto o spoofing + await self._mark_corrupt(declared_hash, storage_path) + return { + "status": "CORRUPT", + "declared": declared_hash, + "calculated": calculated_hash, + "action": "deleted" + } + + # Hash coincide - Marcar como verificado + await self._mark_verified(declared_hash) + + return { + "status": "VERIFIED", + "hash": declared_hash, + "size": len(content) + } + + except Exception as e: + return {"status": "ERROR", "error": str(e)} + + async def _mark_corrupt(self, content_hash: str, storage_path: str): + """Marca blob como corrupto y elimina archivo""" + async with self.pool.acquire() as conn: + await conn.execute(""" + UPDATE storage.physical_blobs + SET verification_status = 'CORRUPT', updated_at = NOW() + WHERE content_hash = $1 + """, content_hash) + + # Eliminar archivo del bucket + try: + self.s3.delete_object(Bucket=R2_BUCKET, Key=storage_path) + except: + pass + + async def _mark_verified(self, content_hash: str): + """Marca blob como verificado""" + async with self.pool.acquire() as conn: + await conn.execute(""" + UPDATE storage.physical_blobs + SET verification_status = 'VERIFIED', + last_verified_at = NOW(), + updated_at = NOW() + WHERE content_hash = $1 + """, content_hash) + + # ========================================================================= + # GENERACIÓN DE DERIVADOS + # ========================================================================= + + async def generate_derivatives(self, content_hash: str) -> Dict[str, Any]: + """Genera thumbnail y metadatos para un blob verificado""" + async with self.pool.acquire() as conn: + blob = await conn.fetchrow(""" + SELECT content_hash, mime_type, storage_path, file_size + FROM storage.physical_blobs + WHERE content_hash = $1 AND verification_status = 'VERIFIED' + """, content_hash) + + if not blob: + return {"status": "ERROR", "error": "Blob not found or not verified"} + + mime_type = blob["mime_type"] + storage_path = blob["storage_path"] + + # Descargar archivo + obj = self.s3.get_object(Bucket=R2_BUCKET, Key=storage_path) + content = obj["Body"].read() + + metadata = { + "content_hash": content_hash, + "mime_type": mime_type, + "file_size": blob["file_size"], + "processed_at": datetime.now().isoformat() + } + + thumb_generated = False + + # Generar thumbnail según tipo + if mime_type.startswith("image/"): + thumb_data, extra_meta = self._process_image(content) + metadata.update(extra_meta) + if thumb_data: + await self._save_thumb(content_hash, thumb_data) + thumb_generated = True + + elif mime_type == "application/pdf": + thumb_data, extra_meta = self._process_pdf(content) + metadata.update(extra_meta) + if thumb_data: + await self._save_thumb(content_hash, thumb_data) + thumb_generated = True + + # Guardar metadatos + await self._save_metadata(content_hash, metadata) + + return { + "status": "OK", + "thumb_generated": thumb_generated, + "metadata": metadata + } + + def _process_image(self, content: bytes) -> tuple: + """Procesa imagen: genera thumb y extrae metadatos""" + try: + img = Image.open(io.BytesIO(content)) + + # Metadatos + meta = { + "width": img.width, + "height": img.height, + "format": img.format, + "mode": img.mode + } + + # EXIF si disponible + if hasattr(img, '_getexif') and img._getexif(): + meta["has_exif"] = True + + # Generar thumbnail + ratio = THUMB_WIDTH / img.width + new_height = int(img.height * ratio) + thumb = img.copy() + thumb.thumbnail((THUMB_WIDTH, new_height), Image.Resampling.LANCZOS) + + # Convertir a bytes + thumb_buffer = io.BytesIO() + thumb.save(thumb_buffer, format="JPEG", quality=85) + thumb_data = thumb_buffer.getvalue() + + return thumb_data, meta + + except Exception as e: + return None, {"error": str(e)} + + def _process_pdf(self, content: bytes) -> tuple: + """Procesa PDF: genera thumb de primera página y extrae metadatos""" + try: + doc = fitz.open(stream=content, filetype="pdf") + + meta = { + "pages": len(doc), + "format": "PDF" + } + + # Metadatos del documento + pdf_meta = doc.metadata + if pdf_meta: + if pdf_meta.get("author"): + meta["author"] = pdf_meta["author"] + if pdf_meta.get("title"): + meta["title"] = pdf_meta["title"] + + # Render primera página como thumbnail + if len(doc) > 0: + page = doc[0] + # Escalar para que el ancho sea THUMB_WIDTH + zoom = THUMB_WIDTH / page.rect.width + mat = fitz.Matrix(zoom, zoom) + pix = page.get_pixmap(matrix=mat) + thumb_data = pix.tobytes("jpeg") + else: + thumb_data = None + + doc.close() + return thumb_data, meta + + except Exception as e: + return None, {"error": str(e)} + + async def _save_thumb(self, content_hash: str, thumb_data: bytes): + """Guarda thumbnail en el bucket""" + key = f"{content_hash}.thumb" + self.s3.put_object( + Bucket=R2_BUCKET, + Key=key, + Body=thumb_data, + ContentType="image/jpeg" + ) + + async def _save_metadata(self, content_hash: str, metadata: dict): + """Guarda metadatos JSON en el bucket""" + key = f"{content_hash}.json" + self.s3.put_object( + Bucket=R2_BUCKET, + Key=key, + Body=json.dumps(metadata, indent=2), + ContentType="application/json" + ) + + # ========================================================================= + # PROCESAMIENTO COMPLETO + # ========================================================================= + + async def process_upload( + self, + declared_hash: str, + storage_path: str, + user_id: str, + original_filename: str, + access_password: Optional[str] = None + ) -> Dict[str, Any]: + """ + Proceso completo post-upload: + 1. Verificar hash + 2. Generar derivados + 3. Crear user_asset + """ + # 1. Verificar hash + verify_result = await self.verify_blob(declared_hash, storage_path) + + if verify_result["status"] != "VERIFIED": + return verify_result + + # 2. Generar derivados (con reintentos) + for attempt in range(MAX_RETRIES): + try: + deriv_result = await self.generate_derivatives(declared_hash) + if deriv_result["status"] == "OK": + break + except Exception as e: + if attempt == MAX_RETRIES - 1: + # Último intento fallido, pero blob ya está verificado + deriv_result = {"status": "PARTIAL", "error": str(e)} + else: + await asyncio.sleep(RETRY_BACKOFF_BASE ** attempt) + + # 3. Crear user_asset + public_key = generate_public_key(declared_hash, user_id) + + async with self.pool.acquire() as conn: + await conn.execute(""" + INSERT INTO storage.user_assets + (public_key, blob_hash, user_id, original_filename, access_password) + VALUES ($1, $2, $3, $4, $5) + """, public_key, declared_hash, user_id, original_filename, access_password) + + return { + "status": "CREATED", + "public_key": public_key, + "content_hash": declared_hash, + "derivatives": deriv_result + } + + # ========================================================================= + # REGISTRO DE BLOB (sin subida - para archivos existentes) + # ========================================================================= + + async def register_blob( + self, + content_hash: str, + file_size: int, + mime_type: str, + storage_provider: str, + storage_path: str + ) -> Dict[str, Any]: + """Registra un blob existente en el sistema""" + async with self.pool.acquire() as conn: + # Verificar si ya existe + existing = await conn.fetchrow(""" + SELECT content_hash, verification_status + FROM storage.physical_blobs + WHERE content_hash = $1 + """, content_hash) + + if existing: + return { + "status": "EXISTS", + "content_hash": content_hash, + "verification_status": existing["verification_status"] + } + + # Insertar nuevo blob + await conn.execute(""" + INSERT INTO storage.physical_blobs + (content_hash, file_size, mime_type, storage_provider, storage_path) + VALUES ($1, $2, $3, $4::storage.storage_provider_enum, $5) + """, content_hash, file_size, mime_type, storage_provider, storage_path) + + return { + "status": "REGISTERED", + "content_hash": content_hash, + "verification_status": "PENDING" + } + + # ========================================================================= + # MANTENIMIENTO + # ========================================================================= + + async def garbage_collect(self, dry_run: bool = True) -> Dict[str, Any]: + """ + Elimina blobs huérfanos (ref_count = 0, sin actualizar en 30 días) + """ + async with self.pool.acquire() as conn: + orphans = await conn.fetch(""" + SELECT content_hash, storage_path + FROM storage.physical_blobs + WHERE ref_count = 0 + AND updated_at < NOW() - INTERVAL '30 days' + """) + + deleted = [] + for blob in orphans: + if not dry_run: + # Eliminar derivados + for ext in [".thumb", ".json"]: + try: + self.s3.delete_object(Bucket=R2_BUCKET, Key=f"{blob['content_hash']}{ext}") + except: + pass + + # Eliminar blob + try: + self.s3.delete_object(Bucket=R2_BUCKET, Key=blob["storage_path"]) + except: + pass + + # Eliminar registro + async with self.pool.acquire() as conn: + await conn.execute(""" + DELETE FROM storage.physical_blobs WHERE content_hash = $1 + """, blob["content_hash"]) + + deleted.append(blob["content_hash"]) + + return { + "status": "OK", + "dry_run": dry_run, + "orphans_found": len(orphans), + "deleted": deleted if not dry_run else [] + } + + async def integrity_check(self, sample_percent: float = 0.01) -> Dict[str, Any]: + """ + Verifica integridad de una muestra aleatoria de blobs + """ + async with self.pool.acquire() as conn: + blobs = await conn.fetch(""" + SELECT content_hash, storage_path + FROM storage.physical_blobs + WHERE verification_status = 'VERIFIED' + ORDER BY RANDOM() + LIMIT (SELECT CEIL(COUNT(*) * $1) FROM storage.physical_blobs WHERE verification_status = 'VERIFIED') + """, sample_percent) + + results = {"checked": 0, "ok": 0, "corrupt": []} + + for blob in blobs: + results["checked"] += 1 + verify = await self.verify_blob(blob["content_hash"], blob["storage_path"]) + + if verify["status"] == "VERIFIED": + results["ok"] += 1 + else: + results["corrupt"].append(blob["content_hash"]) + + return results + + +# CLI para pruebas +async def main(): + import sys + + worker = StorageWorker() + await worker.init() + + if len(sys.argv) < 2: + print("Usage: storage_worker.py [args]") + print("Commands: gc, integrity, register") + return + + cmd = sys.argv[1] + + if cmd == "gc": + dry_run = "--execute" not in sys.argv + result = await worker.garbage_collect(dry_run=dry_run) + print(json.dumps(result, indent=2)) + + elif cmd == "integrity": + result = await worker.integrity_check() + print(json.dumps(result, indent=2)) + + elif cmd == "register": + if len(sys.argv) < 6: + print("Usage: storage_worker.py register ") + return + result = await worker.register_blob( + sys.argv[2], int(sys.argv[3]), sys.argv[4], "R2_PRIMARY", sys.argv[5] + ) + print(json.dumps(result, indent=2)) + + await worker.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/apps/storage/sync_metadata.py b/apps/storage/sync_metadata.py new file mode 100644 index 0000000..e6fca54 --- /dev/null +++ b/apps/storage/sync_metadata.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +Sincronizar metadata desde JSON del bucket R2 a storage.physical_blobs +""" + +import os +import json +import boto3 +import asyncio +import asyncpg + +R2_ENDPOINT = "https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com" +R2_BUCKET = "deck" + + +def get_s3_client(): + return boto3.client( + "s3", + endpoint_url=R2_ENDPOINT, + aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), + aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), + ) + + +async def sync(): + s3 = get_s3_client() + pool = await asyncpg.create_pool( + "postgresql:///tzzr?host=/var/run/postgresql", + min_size=2, max_size=10 + ) + + async with pool.acquire() as conn: + blobs = await conn.fetch(""" + SELECT content_hash, storage_path + FROM storage.physical_blobs + WHERE file_size = 0 + """) + + print(f"Sincronizando metadata para {len(blobs)} blobs...") + + updated = 0 + errors = 0 + + for blob in blobs: + hash = blob["content_hash"] + json_key = f"{hash}.json" + + try: + obj = s3.get_object(Bucket=R2_BUCKET, Key=json_key) + meta = json.loads(obj["Body"].read()) + + # Extraer datos + l2 = meta.get("jsonb_standard", {}).get("L2_document", {}) + size_bytes = l2.get("size_bytes", 0) + mime_type = l2.get("mime_type") + ext = meta.get("ext", "pdf") + url_atc = meta.get("url_atc", []) + storage_path = url_atc[0] if url_atc else f"{hash}.{ext}" + + if not mime_type: + if ext == "pdf": + mime_type = "application/pdf" + elif ext in ("jpg", "jpeg"): + mime_type = "image/jpeg" + elif ext == "png": + mime_type = "image/png" + else: + mime_type = "application/octet-stream" + + # Obtener size real del archivo si no está en JSON + if size_bytes == 0: + try: + file_obj = s3.head_object(Bucket=R2_BUCKET, Key=storage_path) + size_bytes = file_obj.get("ContentLength", 0) + except: + pass + + # Actualizar registro + await conn.execute(""" + UPDATE storage.physical_blobs + SET file_size = $2, + mime_type = $3, + storage_path = $4 + WHERE content_hash = $1 + """, hash, size_bytes, mime_type, storage_path) + + updated += 1 + + if updated % 100 == 0: + print(f" Actualizados: {updated}") + + except s3.exceptions.NoSuchKey: + # JSON no existe, intentar obtener size del archivo directamente + try: + # Probar diferentes extensiones + for ext in ["pdf", "png", "jpg"]: + try: + file_key = f"{hash}.{ext}" + file_obj = s3.head_object(Bucket=R2_BUCKET, Key=file_key) + size_bytes = file_obj.get("ContentLength", 0) + content_type = file_obj.get("ContentType", "application/octet-stream") + + await conn.execute(""" + UPDATE storage.physical_blobs + SET file_size = $2, + mime_type = $3, + storage_path = $4 + WHERE content_hash = $1 + """, hash, size_bytes, content_type, file_key) + + updated += 1 + break + except: + continue + except Exception as e: + errors += 1 + + except Exception as e: + errors += 1 + print(f"Error en {hash}: {e}") + + print(f"\nSincronización completada:") + print(f" - Actualizados: {updated}") + print(f" - Errores: {errors}") + + await pool.close() + + +if __name__ == "__main__": + asyncio.run(sync()) diff --git a/apps/tzzr-cli b/apps/tzzr-cli new file mode 160000 index 0000000..0327df5 --- /dev/null +++ b/apps/tzzr-cli @@ -0,0 +1 @@ +Subproject commit 0327df5277beea172f4f4d4992a0549cd8319815 diff --git a/deck-frontend/backups/20260113_211524/index.html b/deck-frontend/backups/20260113_211524/index.html new file mode 100644 index 0000000..8881f20 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/index.html @@ -0,0 +1,129 @@ + + + + DECK + + + + +
+ +
+
+ + + +
+
+ +
+ + + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+ + +
+
+ + + +
+
+ + + +
+
+
+ + +
+ + +
+ +
+
ALL
+
+ + +
+
+
Cargando...
+
+
+ + +
+
+ + + +
+ + + + diff --git a/deck-frontend/backups/20260113_211524/src/api/client.ts b/deck-frontend/backups/20260113_211524/src/api/client.ts new file mode 100644 index 0000000..03f0f69 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/api/client.ts @@ -0,0 +1,43 @@ +import { API_BASE } from '@/config/index.ts'; + +interface FetchOptions { + method?: 'GET' | 'POST'; + body?: Record; + schema?: string; // PostgREST Accept-Profile header +} + +export async function apiClient( + endpoint: string, + options: FetchOptions = {} +): Promise { + const { method = 'GET', body, schema } = options; + + const headers: Record = {}; + if (body) headers['Content-Type'] = 'application/json'; + if (schema) headers['Accept-Profile'] = schema; + + const config: RequestInit = { + method, + headers: Object.keys(headers).length > 0 ? headers : undefined, + body: body ? JSON.stringify(body) : undefined, + }; + + const response = await fetch(`${API_BASE}${endpoint}`, config); + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + return response.json(); +} + +export async function apiClientSafe( + endpoint: string, + options: FetchOptions = {}, + fallback: T +): Promise { + try { + return await apiClient(endpoint, options); + } catch { + console.error(`API call failed: ${endpoint}`); + return fallback; + } +} diff --git a/deck-frontend/backups/20260113_211524/src/api/graph.ts b/deck-frontend/backups/20260113_211524/src/api/graph.ts new file mode 100644 index 0000000..22b0b66 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/api/graph.ts @@ -0,0 +1,8 @@ +import { apiClientSafe } from './client.ts'; +import type { GraphEdge, TreeEdge } from '@/types/index.ts'; + +export const fetchGraphEdges = (): Promise => + apiClientSafe('/graph_hst', {}, []); + +export const fetchTreeEdges = (): Promise => + apiClientSafe('/tree_hst', {}, []); diff --git a/deck-frontend/backups/20260113_211524/src/api/groups.ts b/deck-frontend/backups/20260113_211524/src/api/groups.ts new file mode 100644 index 0000000..af08297 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/api/groups.ts @@ -0,0 +1,5 @@ +import { apiClientSafe } from './client.ts'; +import type { Group } from '@/types/index.ts'; + +export const fetchGroups = (): Promise => + apiClientSafe('/api_groups', {}, []); diff --git a/deck-frontend/backups/20260113_211524/src/api/index.ts b/deck-frontend/backups/20260113_211524/src/api/index.ts new file mode 100644 index 0000000..8d6cd78 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/api/index.ts @@ -0,0 +1,5 @@ +export { apiClient, apiClientSafe } from './client.ts'; +export { fetchTags, fetchHstTags, fetchChildren, fetchRelated } from './tags.ts'; +export { fetchGroups } from './groups.ts'; +export { fetchLibraries, fetchLibraryMembers } from './libraries.ts'; +export { fetchGraphEdges, fetchTreeEdges } from './graph.ts'; diff --git a/deck-frontend/backups/20260113_211524/src/api/libraries.ts b/deck-frontend/backups/20260113_211524/src/api/libraries.ts new file mode 100644 index 0000000..44f5f3a --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/api/libraries.ts @@ -0,0 +1,26 @@ +import { apiClientSafe } from './client.ts'; +import type { Library, BaseType } from '@/types/index.ts'; + +// Base types that have library tables (public schema taxonomy tables) +const LIBRARY_BASES = new Set(['hst', 'flg', 'itm', 'loc', 'ply']); + +export const fetchLibraries = (base: BaseType): Promise => { + // Only public schema taxonomy tables have libraries + if (!LIBRARY_BASES.has(base)) { + return Promise.resolve([]); + } + // Use base-specific view: api_library_list_hst, api_library_list_flg, etc. + return apiClientSafe(`/api_library_list_${base}`, {}, []); +}; + +export const fetchLibraryMembers = async (mrf: string, base: BaseType): Promise => { + if (!LIBRARY_BASES.has(base)) { + return []; + } + const data = await apiClientSafe>( + `/library_${base}?mrf_library=eq.${mrf}`, + {}, + [] + ); + return data.map(d => d.mrf_tag); +}; diff --git a/deck-frontend/backups/20260113_211524/src/api/tags.ts b/deck-frontend/backups/20260113_211524/src/api/tags.ts new file mode 100644 index 0000000..cba9f0b --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/api/tags.ts @@ -0,0 +1,65 @@ +import { apiClientSafe } from './client.ts'; +import type { Tag, ChildTag, RelatedTag, BaseType } from '@/types/index.ts'; + +// Schema mapping by base type +// - public (default): hst, flg, itm, loc, ply +// - secretaria_clara: atc, mst, bck +// - production_alfred: mth +// - mail_manager: mail (table: clara_registros) +// - context_manager: chat (table: messages) + +interface SchemaTableConfig { + schema: string | null; + table: string; +} + +const getSchemaAndTable = (base: BaseType): SchemaTableConfig => { + switch (base) { + // secretaria_clara schema + case 'atc': + case 'mst': + case 'bck': + return { schema: 'secretaria_clara', table: base }; + + // production_alfred schema + case 'mth': + return { schema: 'production_alfred', table: base }; + + // mail_manager schema + case 'mail': + return { schema: 'mail_manager', table: 'clara_registros' }; + + // context_manager schema + case 'chat': + return { schema: 'context_manager', table: 'messages' }; + + // public schema (default) - hst, flg, itm, loc, ply + default: + return { schema: null, table: base }; + } +}; + +export const fetchTags = (base: BaseType): Promise => { + const { schema, table } = getSchemaAndTable(base); + return apiClientSafe( + `/${table}?order=ref.asc`, + schema ? { schema } : {}, + [] + ); +}; + +// Fetch HST tags for group name resolution (set_hst points to hst tags) +export const fetchHstTags = (): Promise => + apiClientSafe('/hst?select=mrf,ref,alias,name_es,name_en,name_ch', {}, []); + +export const fetchChildren = (mrf: string): Promise => + apiClientSafe('/rpc/api_children', { + method: 'POST', + body: { parent_mrf: mrf } + }, []); + +export const fetchRelated = (mrf: string): Promise => + apiClientSafe('/rpc/api_related', { + method: 'POST', + body: { tag_mrf: mrf } + }, []); diff --git a/deck-frontend/backups/20260113_211524/src/components/Card/Card.ts b/deck-frontend/backups/20260113_211524/src/components/Card/Card.ts new file mode 100644 index 0000000..318db1d --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/components/Card/Card.ts @@ -0,0 +1,47 @@ +import { Component } from '../Component.ts'; +import type { Tag, LangType } from '@/types/index.ts'; +import { getName, getImg } from '@/utils/index.ts'; + +export interface CardProps { + tag: Tag; + lang: LangType; + selected: boolean; + selectionMode: boolean; + onClick: (mrf: string) => void; + onSelect: (mrf: string) => void; +} + +export class Card extends Component { + protected template(): string { + const { tag, lang, selected, selectionMode } = this.props; + const img = getImg(tag); + const name = getName(tag, lang); + + return ` +
+ ${selectionMode ? ` + + ` : ''} + ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 2) || 'T'}
` + } +
${name}
+
+ `; + } + + protected bindEvents(): void { + const { onClick, onSelect, selectionMode } = this.props; + const mrf = this.props.tag.mrf; + + this.element.addEventListener('click', (e) => { + if (selectionMode) { + e.preventDefault(); + onSelect(mrf); + } else { + onClick(mrf); + } + }); + } +} diff --git a/deck-frontend/backups/20260113_211524/src/components/Component.ts b/deck-frontend/backups/20260113_211524/src/components/Component.ts new file mode 100644 index 0000000..f68cf52 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/components/Component.ts @@ -0,0 +1,42 @@ +export abstract class Component

{ + protected element: HTMLElement; + protected props: P; + + constructor(props: P) { + this.props = props; + this.element = this.createElement(); + this.bindEvents(); + } + + protected abstract template(): string; + + protected createElement(): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.innerHTML = this.template().trim(); + return wrapper.firstElementChild as HTMLElement; + } + + protected bindEvents(): void { + // Override in subclasses + } + + public mount(container: HTMLElement): void { + container.appendChild(this.element); + } + + public unmount(): void { + this.element.remove(); + } + + public update(props: Partial

): void { + this.props = { ...this.props, ...props }; + const newElement = this.createElement(); + this.element.replaceWith(newElement); + this.element = newElement; + this.bindEvents(); + } + + public getElement(): HTMLElement { + return this.element; + } +} diff --git a/deck-frontend/backups/20260113_211524/src/components/Modal/Modal.ts b/deck-frontend/backups/20260113_211524/src/components/Modal/Modal.ts new file mode 100644 index 0000000..d3b0bb8 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/components/Modal/Modal.ts @@ -0,0 +1,46 @@ +import { Component } from '../Component.ts'; + +export interface ModalProps { + title: string; + content: string; + isOpen: boolean; + onClose: () => void; +} + +export class Modal extends Component { + protected template(): string { + const { title, content, isOpen } = this.props; + return ` +

+ `; + } + + protected bindEvents(): void { + const closeBtn = this.element.querySelector('.modal-close'); + closeBtn?.addEventListener('click', this.props.onClose); + + this.element.addEventListener('click', (e) => { + if (e.target === this.element) { + this.props.onClose(); + } + }); + } + + public open(): void { + this.element.classList.add('open'); + } + + public close(): void { + this.element.classList.remove('open'); + } +} diff --git a/deck-frontend/backups/20260113_211524/src/components/TagChip/TagChip.ts b/deck-frontend/backups/20260113_211524/src/components/TagChip/TagChip.ts new file mode 100644 index 0000000..271bc5e --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/components/TagChip/TagChip.ts @@ -0,0 +1,25 @@ +import { Component } from '../Component.ts'; + +export interface TagChipProps { + mrf: string; + label: string; + title?: string; + onClick: (mrf: string) => void; +} + +export class TagChip extends Component { + protected template(): string { + const { mrf, label, title } = this.props; + return ` + + ${label} + + `; + } + + protected bindEvents(): void { + this.element.addEventListener('click', () => { + this.props.onClick(this.props.mrf); + }); + } +} diff --git a/deck-frontend/backups/20260113_211524/src/components/index.ts b/deck-frontend/backups/20260113_211524/src/components/index.ts new file mode 100644 index 0000000..df484ec --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/components/index.ts @@ -0,0 +1,4 @@ +export { Component } from './Component.ts'; +export { Card, type CardProps } from './Card/Card.ts'; +export { TagChip, type TagChipProps } from './TagChip/TagChip.ts'; +export { Modal, type ModalProps } from './Modal/Modal.ts'; diff --git a/deck-frontend/backups/20260113_211524/src/config/api.ts b/deck-frontend/backups/20260113_211524/src/config/api.ts new file mode 100644 index 0000000..7030377 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/config/api.ts @@ -0,0 +1 @@ +export const API_BASE = '/api'; diff --git a/deck-frontend/backups/20260113_211524/src/config/categories.ts b/deck-frontend/backups/20260113_211524/src/config/categories.ts new file mode 100644 index 0000000..a19de89 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/config/categories.ts @@ -0,0 +1,15 @@ +import type { CategoryKey } from '@/types/index.ts'; + +export interface CategoryConfig { + name: string; + color: string; +} + +export const CATS: Record = { + hst: { name: 'Hashtags', color: '#7c8aff' }, + spe: { name: 'Specs', color: '#FF9800' }, + vue: { name: 'Values', color: '#00BCD4' }, + vsn: { name: 'Visions', color: '#E91E63' }, + msn: { name: 'Missions', color: '#9C27B0' }, + flg: { name: 'Flags', color: '#4CAF50' } +}; diff --git a/deck-frontend/backups/20260113_211524/src/config/edges.ts b/deck-frontend/backups/20260113_211524/src/config/edges.ts new file mode 100644 index 0000000..ab64dd8 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/config/edges.ts @@ -0,0 +1,14 @@ +import type { EdgeType } from '@/types/index.ts'; + +export const EDGE_COLORS: Record = { + relation: '#8BC34A', + specialization: '#9C27B0', + mirror: '#607D8B', + dependency: '#2196F3', + sequence: '#4CAF50', + composition: '#FF9800', + hierarchy: '#E91E63', + library: '#00BCD4', + contextual: '#FFC107', + association: '#795548' +}; diff --git a/deck-frontend/backups/20260113_211524/src/config/index.ts b/deck-frontend/backups/20260113_211524/src/config/index.ts new file mode 100644 index 0000000..23dfe12 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/config/index.ts @@ -0,0 +1,3 @@ +export { CATS, type CategoryConfig } from './categories.ts'; +export { EDGE_COLORS } from './edges.ts'; +export { API_BASE } from './api.ts'; diff --git a/deck-frontend/backups/20260113_211524/src/main.ts b/deck-frontend/backups/20260113_211524/src/main.ts new file mode 100644 index 0000000..d08978e --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/main.ts @@ -0,0 +1,484 @@ +import { store } from '@/state/index.ts'; +import { Router } from '@/router/index.ts'; +import { fetchTags, fetchHstTags, fetchGroups, fetchLibraries, fetchLibraryMembers } from '@/api/index.ts'; +import { GridView, TreeView, GraphView, DetailPanel } from '@/views/index.ts'; +import { $, $$, delegateEvent, toast, createNameMap, resolveGroupName } from '@/utils/index.ts'; +import { CATS, EDGE_COLORS } from '@/config/index.ts'; +import type { BaseType, ViewType, CategoryKey, EdgeType } from '@/types/index.ts'; +import './styles/main.css'; + +class App { + private router: Router; + private currentView: GridView | TreeView | GraphView | null = null; + private detailPanel: DetailPanel | null = null; + + constructor() { + this.router = new Router(store, () => this.init()); + } + + async start(): Promise { + this.router.parseHash(); + await this.init(); + this.bindEvents(); + } + + private async init(): Promise { + const contentArea = $('#content-area'); + const detailPanelEl = $('#detail-panel'); + if (!contentArea || !detailPanelEl) return; + + // Update UI + this.updateBaseButtons(); + this.updateViewTabs(); + + // Show loading + contentArea.innerHTML = '
Cargando...
'; + + // Fetch data + const state = store.getState(); + const [tags, hstTags, groups, libraries] = await Promise.all([ + fetchTags(state.base), + fetchHstTags(), // Always load HST for group name resolution + fetchGroups(), + fetchLibraries(state.base) // Load libraries for current base + ]); + store.setState({ tags, hstTags, groups, libraries }); + + // Render groups + this.renderGroups(); + this.renderLibraries(); + + // Setup detail panel + if (!this.detailPanel) { + this.detailPanel = new DetailPanel(detailPanelEl, store); + } + + // Render view + this.renderView(); + } + + private renderView(): void { + const contentArea = $('#content-area'); + if (!contentArea) return; + + const state = store.getState(); + const showDetail = (mrf: string) => this.detailPanel?.showDetail(mrf); + + // Unmount current view + this.currentView?.unmount(); + + // Clear and set class + contentArea.innerHTML = ''; + contentArea.className = `content-area ${state.view}-view`; + + // Mount new view + switch (state.view) { + case 'grid': + this.currentView = new GridView(contentArea, store, showDetail); + this.currentView.mount(); + break; + case 'tree': + this.currentView = new TreeView(contentArea, store, showDetail); + this.currentView.mount(); + break; + case 'graph': + this.currentView = new GraphView(contentArea, store, showDetail); + (this.currentView as GraphView).mount(); + break; + } + } + + private renderGroups(): void { + const container = $('#groups-bar'); + if (!container) return; + + const state = store.getState(); + // Use hstTags for group name resolution (set_hst points to hst tags) + const nameMap = createNameMap(state.hstTags, state.lang); + + // Count tags per group + const counts = new Map(); + state.tags.forEach(tag => { + const group = tag.set_hst || 'sin-grupo'; + counts.set(group, (counts.get(group) || 0) + 1); + }); + + // Sort by count and take top 20 + const sorted = Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20); + + container.innerHTML = ` + + ${sorted.map(([groupMrf, count]) => { + const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap); + return ` + + `; + }).join('')} + `; + + delegateEvent(container, '.group-btn', 'click', (_, target) => { + const group = target.dataset.group || 'all'; + store.setState({ group }); + this.renderGroups(); + this.renderView(); + }); + } + + private renderLibraries(): void { + const container = $('#left-panel'); + if (!container) return; + + const state = store.getState(); + + // Show graph options when in graph view + if (state.view === 'graph') { + container.classList.add('graph-mode'); + this.renderGraphOptions(container); + return; + } + + container.classList.remove('graph-mode'); + container.innerHTML = ` +
+ ALL +
+ ${state.libraries.map(lib => { + const icon = lib.img_thumb_url || lib.icon_url || ''; + const name = lib.name || lib.name_es || lib.alias || lib.ref || lib.mrf.slice(0, 6); + return ` +
+ ${icon ? `` : ''} + ${name.slice(0, 8)} +
+ `; + }).join('')} + `; + + delegateEvent(container, '.lib-icon', 'click', async (_, target) => { + const library = target.dataset.lib || 'all'; + const currentBase = store.getState().base; + + if (library === 'all') { + store.setState({ library: 'all', libraryMembers: new Set() }); + } else { + const members = await fetchLibraryMembers(library, currentBase); + store.setState({ library, libraryMembers: new Set(members) }); + } + + this.renderLibraries(); + this.renderView(); + }); + } + + private renderGraphOptions(container: HTMLElement): void { + const state = store.getState(); + const { graphFilters, graphSettings, tags, graphEdges } = state; + + // Count nodes and edges + const nodeCount = tags.length; + const edgeCount = graphEdges.length; + + container.innerHTML = ` +
+ +
+
+ Nodos + ${nodeCount} +
+
+ Edges + ${edgeCount} +
+
+ + +
+
Categorias
+ ${Object.entries(CATS).map(([key, config]) => ` + + `).join('')} +
+ + +
+
Relaciones
+ ${Object.entries(EDGE_COLORS).map(([key, color]) => ` + + `).join('')} +
+ + +
+
Visualizacion
+ + +
+
+ Nodo + ${graphSettings.nodeSize}px +
+ +
+
+
+ Distancia + ${graphSettings.linkDist}px +
+ +
+
+
+ `; + + // Bind events + this.bindGraphOptionEvents(container); + } + + private bindGraphOptionEvents(container: HTMLElement): void { + // Category checkboxes + container.querySelectorAll('[data-cat]').forEach(cb => { + cb.addEventListener('change', () => { + const cat = cb.dataset.cat as CategoryKey; + const state = store.getState(); + const newCats = new Set(state.graphFilters.cats); + if (cb.checked) { + newCats.add(cat); + } else { + newCats.delete(cat); + } + store.setState({ + graphFilters: { ...state.graphFilters, cats: newCats } + }); + this.renderView(); + }); + }); + + // Edge checkboxes + container.querySelectorAll('[data-edge]').forEach(cb => { + cb.addEventListener('change', () => { + const edge = cb.dataset.edge as EdgeType; + const state = store.getState(); + const newEdges = new Set(state.graphFilters.edges); + if (cb.checked) { + newEdges.add(edge); + } else { + newEdges.delete(edge); + } + store.setState({ + graphFilters: { ...state.graphFilters, edges: newEdges } + }); + this.renderView(); + }); + }); + + // Show images checkbox + const showImgCb = container.querySelector('#graph-show-img'); + showImgCb?.addEventListener('change', () => { + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, showImg: showImgCb.checked } + }); + this.renderView(); + }); + + // Show labels checkbox + const showLblCb = container.querySelector('#graph-show-lbl'); + showLblCb?.addEventListener('change', () => { + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, showLbl: showLblCb.checked } + }); + this.renderView(); + }); + + // Node size slider + const nodeSizeSlider = container.querySelector('#graph-node-size'); + const nodeSizeVal = container.querySelector('#node-size-val'); + nodeSizeSlider?.addEventListener('input', () => { + const size = parseInt(nodeSizeSlider.value, 10); + if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`; + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, nodeSize: size } + }); + this.renderView(); + }); + + // Link distance slider + const linkDistSlider = container.querySelector('#graph-link-dist'); + const linkDistVal = container.querySelector('#link-dist-val'); + linkDistSlider?.addEventListener('input', () => { + const dist = parseInt(linkDistSlider.value, 10); + if (linkDistVal) linkDistVal.textContent = `${dist}px`; + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, linkDist: dist } + }); + this.renderView(); + }); + } + + private updateBaseButtons(): void { + const state = store.getState(); + $$('.base-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.base === state.base); + }); + } + + private updateViewTabs(): void { + const state = store.getState(); + $$('.view-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.view === state.view); + }); + } + + private bindEvents(): void { + // Base buttons + delegateEvent(document.body, '.base-btn', 'click', async (_, target) => { + const base = target.dataset.base as BaseType; + if (!base) return; + + store.setState({ + base, + group: 'all', + library: 'all', + libraryMembers: new Set(), + search: '', + graphEdges: [], + treeEdges: [], + selected: new Set(), + selectionMode: false + }); + + this.router.updateHash(); + await this.init(); + }); + + // View tabs + delegateEvent(document.body, '.view-tab', 'click', (_, target) => { + const view = target.dataset.view as ViewType; + if (!view) return; + + store.setState({ view }); + this.router.updateHash(); + this.detailPanel?.close(); + this.updateViewTabs(); + this.renderLibraries(); // Update left panel (graph options vs libraries) + this.renderView(); + }); + + // Search + const searchInput = $('#search') as HTMLInputElement; + if (searchInput) { + let timeout: number; + searchInput.addEventListener('input', () => { + clearTimeout(timeout); + timeout = window.setTimeout(() => { + store.setState({ search: searchInput.value }); + this.renderView(); + }, 200); + }); + } + + // Language select + const langSelect = $('#lang-select') as HTMLSelectElement; + if (langSelect) { + langSelect.addEventListener('change', () => { + store.setState({ lang: langSelect.value as 'es' | 'en' | 'ch' }); + this.renderView(); + }); + } + + // Selection mode + const selBtn = $('#btn-sel'); + if (selBtn) { + selBtn.addEventListener('click', () => { + const state = store.getState(); + store.setState({ + selectionMode: !state.selectionMode, + selected: state.selectionMode ? new Set() : state.selected + }); + selBtn.classList.toggle('active', !state.selectionMode); + this.updateSelectionCount(); + this.renderView(); + }); + } + + // Get selected + const getBtn = $('#btn-get'); + if (getBtn) { + getBtn.addEventListener('click', () => { + const state = store.getState(); + if (state.selected.size === 0) { + toast('No hay seleccionados'); + return; + } + navigator.clipboard.writeText([...state.selected].join('\n')) + .then(() => toast(`Copiados ${state.selected.size} mrfs`)); + }); + } + + // API modal + const apiBtn = $('#btn-api'); + const apiModal = $('#api-modal'); + if (apiBtn && apiModal) { + apiBtn.addEventListener('click', () => apiModal.classList.add('open')); + apiModal.addEventListener('click', (e) => { + if (e.target === apiModal) apiModal.classList.remove('open'); + }); + const closeBtn = apiModal.querySelector('.modal-close'); + closeBtn?.addEventListener('click', () => apiModal.classList.remove('open')); + } + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.detailPanel?.close(); + $('#api-modal')?.classList.remove('open'); + if (store.getState().selectionMode) { + store.setState({ selectionMode: false, selected: new Set() }); + $('#btn-sel')?.classList.remove('active'); + this.renderView(); + } + } + if (e.key === '/' && (e.target as HTMLElement).tagName !== 'INPUT') { + e.preventDefault(); + ($('#search') as HTMLInputElement)?.focus(); + } + }); + } + + private updateSelectionCount(): void { + const counter = $('#sel-count'); + if (counter) { + const count = store.getState().selected.size; + counter.textContent = count > 0 ? `(${count})` : ''; + } + } +} + +// Bootstrap +document.addEventListener('DOMContentLoaded', () => { + new App().start(); +}); diff --git a/deck-frontend/backups/20260113_211524/src/modules/configs/index.ts b/deck-frontend/backups/20260113_211524/src/modules/configs/index.ts new file mode 100644 index 0000000..0396879 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/modules/configs/index.ts @@ -0,0 +1,368 @@ +/** + * Module Configurations - Registro central de todos los módulos + */ + +import type { BaseConfig, ModuleCategory } from '../registry.ts'; +import type { BaseType, ViewType } from '@/types/index.ts'; + +// Configuración de todos los módulos +export const MODULE_CONFIGS: Record = { + // ═══════════════════════════════════════════════════════════════ + // TAXONOMÍA (public schema) + // ═══════════════════════════════════════════════════════════════ + hst: { + id: 'hst', + name: 'Hashtags Semánticos', + shortName: 'HST', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: true }, + defaultView: 'grid', + api: { + schema: null, + table: 'hst', + hasLibraries: true, + hasGroups: true, + hasGraph: true, + hasTree: true + }, + enabled: true + }, + + flg: { + id: 'flg', + name: 'Flags', + shortName: 'FLG', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'flg', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + itm: { + id: 'itm', + name: 'Items', + shortName: 'ITM', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'itm', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + loc: { + id: 'loc', + name: 'Locations', + shortName: 'LOC', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'loc', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + ply: { + id: 'ply', + name: 'Players', + shortName: 'PLY', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'ply', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + // ═══════════════════════════════════════════════════════════════ + // MAESTROS (secretaria_clara schema) + // ═══════════════════════════════════════════════════════════════ + mst: { + id: 'mst', + name: 'Masters', + shortName: 'MST', + category: 'masters', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'secretaria_clara', + table: 'mst', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + bck: { + id: 'bck', + name: 'Backups', + shortName: 'BCK', + category: 'masters', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'secretaria_clara', + table: 'bck', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + // ═══════════════════════════════════════════════════════════════ + // REGISTRO (secretaria_clara / production_alfred) + // ═══════════════════════════════════════════════════════════════ + atc: { + id: 'atc', + name: 'Attachments', + shortName: 'ATC', + category: 'registry', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'secretaria_clara', + table: 'atc', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + mth: { + id: 'mth', + name: 'Methods', + shortName: 'MTH', + category: 'registry', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'production_alfred', + table: 'mth', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + // ═══════════════════════════════════════════════════════════════ + // COMUNICACIÓN (mail_manager / context_manager) + // Interfaz de chat con IA + // ═══════════════════════════════════════════════════════════════ + mail: { + id: 'mail', + name: 'Mail Assistant', + shortName: 'MAIL', + category: 'communication', + renderType: 'chat', + views: { custom: 'ChatView' }, + defaultView: 'custom', + api: { + schema: 'mail_manager', + table: 'clara_registros', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/MailModule.ts'), + enabled: false // Próximamente + }, + + chat: { + id: 'chat', + name: 'Context Manager', + shortName: 'CHAT', + category: 'communication', + renderType: 'chat', + views: { custom: 'ChatView' }, + defaultView: 'custom', + api: { + schema: 'context_manager', + table: 'messages', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/ContextModule.ts'), + enabled: false // Próximamente + }, + + // ═══════════════════════════════════════════════════════════════ + // SERVICIOS (interfaces custom) + // ═══════════════════════════════════════════════════════════════ + key: { + id: 'key', + name: 'Keys', + shortName: 'KEY', + category: 'services', + renderType: 'custom', + views: { custom: 'KeyView' }, + defaultView: 'custom', + api: { + schema: null, + table: 'key', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/KeyModule.ts'), + enabled: false // Próximamente + }, + + mindlink: { + id: 'mindlink', + name: 'MindLink', + shortName: 'MIND', + category: 'services', + renderType: 'custom', + views: { custom: 'MindlinkView' }, + defaultView: 'custom', + api: { + schema: null, + table: 'mindlink', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/MindlinkModule.ts'), + enabled: false // Próximamente + } +}; + +// ═══════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════ + +/** + * Obtener configuración de un módulo + */ +export const getModuleConfig = (base: BaseType): BaseConfig => { + const config = MODULE_CONFIGS[base]; + if (!config) { + throw new Error(`Module config not found for base: ${base}`); + } + return config; +}; + +/** + * Agrupar módulos por categoría para UI + */ +export const getModulesByCategory = (): Record => { + const result: Record = { + taxonomy: [], + masters: [], + registry: [], + communication: [], + services: [] + }; + + Object.values(MODULE_CONFIGS).forEach(config => { + result[config.category].push(config); + }); + + return result; +}; + +/** + * Obtener solo módulos habilitados + */ +export const getEnabledModules = (): BaseConfig[] => { + return Object.values(MODULE_CONFIGS).filter(c => c.enabled !== false); +}; + +/** + * Verificar si un módulo está habilitado + */ +export const isModuleEnabled = (base: BaseType): boolean => { + return MODULE_CONFIGS[base]?.enabled !== false; +}; + +/** + * Obtener schema y tabla para API (compatibilidad con código existente) + */ +export const getSchemaAndTable = (base: BaseType): { schema: string | null; table: string } => { + const config = MODULE_CONFIGS[base]; + if (!config) { + return { schema: null, table: base }; + } + return { + schema: config.api.schema, + table: config.api.table + }; +}; + +/** + * Verificar si una base soporta bibliotecas + */ +export const supportsLibraries = (base: BaseType): boolean => { + return MODULE_CONFIGS[base]?.api.hasLibraries ?? false; +}; + +/** + * Verificar si una base soporta grupos + */ +export const supportsGroups = (base: BaseType): boolean => { + return MODULE_CONFIGS[base]?.api.hasGroups ?? false; +}; + +/** + * Verificar si una vista está soportada por una base + */ +export const supportsView = (base: BaseType, view: ViewType): boolean => { + const config = MODULE_CONFIGS[base]; + if (!config) return false; + return !!config.views[view]; +}; + +/** + * Obtener vista por defecto de una base + */ +export const getDefaultView = (base: BaseType): ViewType | 'custom' => { + return MODULE_CONFIGS[base]?.defaultView ?? 'grid'; +}; diff --git a/deck-frontend/backups/20260113_211524/src/modules/custom/ContextModule.ts b/deck-frontend/backups/20260113_211524/src/modules/custom/ContextModule.ts new file mode 100644 index 0000000..da0a0cf --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/modules/custom/ContextModule.ts @@ -0,0 +1,31 @@ +/** + * ContextModule - Módulo de chat con IA genérico + * + * TODO: Implementar interfaz de chat estilo ChatGPT/Claude + * para interactuar con diferentes modelos de IA controlando el contexto. + */ + +import { BaseModule } from '../registry.ts'; + +export class ContextModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
💬
+
Context Manager
+
Chat con IA - Próximamente
+
+ `; + } +} + +export default ContextModule; diff --git a/deck-frontend/backups/20260113_211524/src/modules/custom/KeyModule.ts b/deck-frontend/backups/20260113_211524/src/modules/custom/KeyModule.ts new file mode 100644 index 0000000..b5e20d2 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/modules/custom/KeyModule.ts @@ -0,0 +1,30 @@ +/** + * KeyModule - Módulo de gestión de claves + * + * TODO: Implementar interfaz para gestionar claves y credenciales. + */ + +import { BaseModule } from '../registry.ts'; + +export class KeyModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
🔑
+
Keys
+
Gestión de claves - Próximamente
+
+ `; + } +} + +export default KeyModule; diff --git a/deck-frontend/backups/20260113_211524/src/modules/custom/MailModule.ts b/deck-frontend/backups/20260113_211524/src/modules/custom/MailModule.ts new file mode 100644 index 0000000..1f5d816 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/modules/custom/MailModule.ts @@ -0,0 +1,31 @@ +/** + * MailModule - Módulo de chat con IA para mail + * + * TODO: Implementar interfaz de chat estilo ChatGPT/Claude + * donde el contexto es el correo electrónico. + */ + +import { BaseModule } from '../registry.ts'; + +export class MailModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
📧
+
Mail Assistant
+
Interfaz de chat con IA - Próximamente
+
+ `; + } +} + +export default MailModule; diff --git a/deck-frontend/backups/20260113_211524/src/modules/custom/MindlinkModule.ts b/deck-frontend/backups/20260113_211524/src/modules/custom/MindlinkModule.ts new file mode 100644 index 0000000..64073d5 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/modules/custom/MindlinkModule.ts @@ -0,0 +1,31 @@ +/** + * MindlinkModule - Módulo de gestión de hipervínculos + * + * TODO: Implementar interfaz de árboles visuales con imágenes + * y vínculos a archivos/recursos. + */ + +import { BaseModule } from '../registry.ts'; + +export class MindlinkModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
🔗
+
MindLink
+
Gestión de hipervínculos - Próximamente
+
+ `; + } +} + +export default MindlinkModule; diff --git a/deck-frontend/backups/20260113_211524/src/modules/custom/index.ts b/deck-frontend/backups/20260113_211524/src/modules/custom/index.ts new file mode 100644 index 0000000..3595038 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/modules/custom/index.ts @@ -0,0 +1,4 @@ +export { MailModule } from './MailModule.ts'; +export { ContextModule } from './ContextModule.ts'; +export { KeyModule } from './KeyModule.ts'; +export { MindlinkModule } from './MindlinkModule.ts'; diff --git a/deck-frontend/backups/20260113_211524/src/modules/index.ts b/deck-frontend/backups/20260113_211524/src/modules/index.ts new file mode 100644 index 0000000..71eb421 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/modules/index.ts @@ -0,0 +1,35 @@ +/** + * Modules - Sistema modular para DECK Frontend + */ + +// Registry (tipos e interfaces) +export { + type ModuleRenderType, + type ModuleCategory, + type ModuleViews, + type ModuleApiConfig, + type BaseConfig, + type ModuleState, + type ModuleContext, + BaseModule +} from './registry.ts'; + +// Configs (registro de módulos) +export { + MODULE_CONFIGS, + getModuleConfig, + getModulesByCategory, + getEnabledModules, + isModuleEnabled, + getSchemaAndTable, + supportsLibraries, + supportsGroups, + supportsView, + getDefaultView +} from './configs/index.ts'; + +// Loader +export { ModuleLoader, type LoaderTargets } from './loader.ts'; + +// Standard module +export { StandardModule } from './standard/index.ts'; diff --git a/deck-frontend/backups/20260113_211524/src/modules/loader.ts b/deck-frontend/backups/20260113_211524/src/modules/loader.ts new file mode 100644 index 0000000..3ced06f --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/modules/loader.ts @@ -0,0 +1,174 @@ +/** + * ModuleLoader - Carga dinámica de módulos + * + * Responsabilidades: + * - Cargar el módulo correcto según la base + * - Manejar cache de módulos + * - Gestionar lifecycle (mount/unmount) + */ + +import { BaseModule, type ModuleContext } from './registry.ts'; +import { getModuleConfig, isModuleEnabled } from './configs/index.ts'; +import { StandardModule } from './standard/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, BaseType } from '@/types/index.ts'; + +export interface LoaderTargets { + container: HTMLElement; + leftPanel: HTMLElement; + groupsBar: HTMLElement; + showDetail: (mrf: string) => void; +} + +export class ModuleLoader { + private store: Store; + private currentModule: BaseModule | null = null; + private currentBase: BaseType | null = null; + + constructor(store: Store) { + this.store = store; + } + + /** + * Cargar módulo para una base + */ + async load(base: BaseType, targets: LoaderTargets): Promise { + const config = getModuleConfig(base); + + // Verificar si el módulo está habilitado + if (!isModuleEnabled(base)) { + this.showDisabledMessage(targets.container, config.name); + targets.leftPanel.innerHTML = ''; + targets.groupsBar.innerHTML = ''; + return; + } + + // Si ya está cargado el mismo módulo, solo re-renderizar + if (this.currentBase === base && this.currentModule) { + this.currentModule.render(); + return; + } + + // Unmount módulo actual + this.currentModule?.unmount(); + this.currentModule = null; + this.currentBase = null; + + // Show loading + targets.container.innerHTML = '
Cargando...
'; + + // Crear contexto + const ctx: ModuleContext = { + container: targets.container, + leftPanel: targets.leftPanel, + groupsBar: targets.groupsBar, + store: this.store, + config, + showDetail: targets.showDetail + }; + + // Crear módulo según tipo + let module: BaseModule; + + switch (config.renderType) { + case 'standard': + module = new StandardModule(ctx); + break; + + case 'chat': + case 'custom': + // Carga dinámica de módulos custom + if (config.customModule) { + try { + const { default: CustomModule } = await config.customModule(); + module = new CustomModule(ctx); + } catch (error) { + console.error(`Failed to load custom module for ${base}:`, error); + this.showErrorMessage(targets.container, `Error cargando módulo ${config.name}`); + return; + } + } else { + this.showDisabledMessage(targets.container, config.name); + return; + } + break; + + default: + console.error(`Unknown render type: ${config.renderType}`); + this.showErrorMessage(targets.container, 'Tipo de módulo desconocido'); + return; + } + + // Mount módulo + try { + await module.mount(); + this.currentModule = module; + this.currentBase = base; + } catch (error) { + console.error(`Failed to mount module for ${base}:`, error); + this.showErrorMessage(targets.container, `Error inicializando ${config.name}`); + } + } + + /** + * Re-renderizar módulo actual (ej: cuando cambia la vista) + */ + rerender(): void { + if (this.currentModule) { + this.currentModule.render(); + } + } + + /** + * Re-renderizar sidebar del módulo actual + */ + rerenderSidebar(): void { + if (this.currentModule) { + this.currentModule.renderSidebar(); + } + } + + /** + * Obtener módulo actual + */ + getCurrentModule(): BaseModule | null { + return this.currentModule; + } + + /** + * Obtener base actual + */ + getCurrentBase(): BaseType | null { + return this.currentBase; + } + + /** + * Unmount módulo actual + */ + unmount(): void { + this.currentModule?.unmount(); + this.currentModule = null; + this.currentBase = null; + } + + private showDisabledMessage(container: HTMLElement, moduleName: string): void { + container.innerHTML = ` +
+
🚧
+
${moduleName}
+
Próximamente
+
+ `; + } + + private showErrorMessage(container: HTMLElement, message: string): void { + container.innerHTML = ` +
+
⚠️
+
${message}
+
+ `; + } +} + +export default ModuleLoader; diff --git a/deck-frontend/backups/20260113_211524/src/modules/registry.ts b/deck-frontend/backups/20260113_211524/src/modules/registry.ts new file mode 100644 index 0000000..7940f0c --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/modules/registry.ts @@ -0,0 +1,170 @@ +/** + * Module Registry - Tipos e interfaces para el sistema modular + */ + +import type { Store } from '@/state/store.ts'; +import type { AppState, ViewType, BaseType } from '@/types/index.ts'; + +// Tipos de renderizado de módulos +export type ModuleRenderType = + | 'standard' // Grid/Tree/Graph normal (taxonomía, atc) + | 'chat' // Interfaz de chat con IA (mail, context) + | 'custom'; // Interfaz completamente custom (key, mindlink) + +// Categorías de módulos para agrupar en UI +export type ModuleCategory = + | 'taxonomy' // hst, flg, itm, loc, ply + | 'masters' // mst, bck + | 'registry' // atc, mth + | 'communication' // mail, chat + | 'services'; // key, mindlink + +// Configuración de vistas soportadas por módulo +export interface ModuleViews { + grid?: boolean; + tree?: boolean; + graph?: boolean; + custom?: string; // Nombre del componente custom a usar +} + +// Configuración de API por módulo +export interface ModuleApiConfig { + schema: string | null; // PostgREST schema (null = public) + table: string; // Tabla principal + hasLibraries?: boolean; // ¿Soporta bibliotecas? + hasGroups?: boolean; // ¿Soporta grupos (set_hst)? + hasGraph?: boolean; // ¿Tiene datos de grafo? + hasTree?: boolean; // ¿Tiene datos de árbol? +} + +// Configuración completa de un módulo +export interface BaseConfig { + id: BaseType; + name: string; // Nombre completo + shortName: string; // Para botón (3-4 chars) + category: ModuleCategory; + renderType: ModuleRenderType; + + // Vistas soportadas + views: ModuleViews; + defaultView: ViewType | 'custom'; + + // API + api: ModuleApiConfig; + + // Para módulos custom (lazy loading) + customModule?: () => Promise<{ default: new (ctx: ModuleContext) => BaseModule }>; + + // Estado inicial específico del módulo + initialState?: Partial; + + // Módulo habilitado (false = mostrar "Próximamente") + enabled?: boolean; +} + +// Estado específico de un módulo +export interface ModuleState { + loading: boolean; + error: string | null; + data: unknown; +} + +// Contexto pasado a cada módulo +export interface ModuleContext { + container: HTMLElement; + leftPanel: HTMLElement; + groupsBar: HTMLElement; + store: Store; + config: BaseConfig; + showDetail: (mrf: string) => void; +} + +// Clase base abstracta para módulos +export abstract class BaseModule { + protected ctx: ModuleContext; + protected mounted = false; + protected unsubscribe: (() => void) | null = null; + + constructor(ctx: ModuleContext) { + this.ctx = ctx; + } + + // Lifecycle + abstract mount(): Promise; + abstract unmount(): void; + abstract render(): void; + + // Override para carga de datos específica + async loadData(): Promise { + // Default: no hace nada, subclases implementan + } + + // Override para contenido del sidebar (libraries/options) + renderSidebar(): void { + // Default: vacío + this.ctx.leftPanel.innerHTML = ''; + } + + // Override para barra de grupos + renderGroupsBar(): void { + // Default: vacío + this.ctx.groupsBar.innerHTML = ''; + } + + // Helpers + protected getState(): Readonly { + return this.ctx.store.getState(); + } + + protected setState(partial: Partial): void { + this.ctx.store.setState(partial); + } + + protected getConfig(): BaseConfig { + return this.ctx.config; + } + + protected subscribe(listener: (state: AppState) => void): void { + this.unsubscribe = this.ctx.store.subscribe(listener); + } + + // Verificar si una vista está soportada + protected isViewSupported(view: ViewType): boolean { + return !!this.ctx.config.views[view]; + } +} + +// Helper para obtener config de módulo +export const getModuleConfig = (configs: Record, base: BaseType): BaseConfig => { + const config = configs[base]; + if (!config) { + throw new Error(`Module config not found for base: ${base}`); + } + return config; +}; + +// Helper para agrupar módulos por categoría +export const getModulesByCategory = ( + configs: Record +): Record => { + const result: Record = { + taxonomy: [], + masters: [], + registry: [], + communication: [], + services: [] + }; + + Object.values(configs).forEach(config => { + result[config.category].push(config); + }); + + return result; +}; + +// Helper para obtener módulos habilitados +export const getEnabledModules = ( + configs: Record +): BaseConfig[] => { + return Object.values(configs).filter(c => c.enabled !== false); +}; diff --git a/deck-frontend/backups/20260113_211524/src/modules/standard/StandardModule.ts b/deck-frontend/backups/20260113_211524/src/modules/standard/StandardModule.ts new file mode 100644 index 0000000..24c5d6e --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/modules/standard/StandardModule.ts @@ -0,0 +1,323 @@ +/** + * StandardModule - Módulo estándar para bases con vistas Grid/Tree/Graph + * + * Usado por: taxonomía (hst, flg, itm, loc, ply), maestros (mst, bck), + * registro (atc, mth) + */ + +import { BaseModule } from '../registry.ts'; +import { GridView, TreeView, GraphView } from '@/views/index.ts'; +import { fetchTags, fetchHstTags, fetchGroups, fetchLibraries, fetchLibraryMembers } from '@/api/index.ts'; +import { createNameMap, resolveGroupName, delegateEvent } from '@/utils/index.ts'; +import type { ViewType } from '@/types/index.ts'; + +export class StandardModule extends BaseModule { + private currentView: GridView | TreeView | GraphView | null = null; + + async mount(): Promise { + // Show loading + this.ctx.container.innerHTML = '
Cargando...
'; + + // Load data + await this.loadData(); + + // Render sidebar and groups + this.renderSidebar(); + this.renderGroupsBar(); + + // Render main view + this.render(); + + this.mounted = true; + } + + unmount(): void { + this.currentView?.unmount(); + this.currentView = null; + this.unsubscribe?.(); + this.unsubscribe = null; + this.mounted = false; + } + + async loadData(): Promise { + const config = this.getConfig(); + + // Fetch tags para esta base + const tags = await fetchTags(config.id); + + // Fetch HST tags para resolución de nombres de grupos (si tiene grupos) + const hstTags = config.api.hasGroups + ? await fetchHstTags() + : []; + + // Fetch grupos (solo si esta base los soporta) + const groups = config.api.hasGroups + ? await fetchGroups() + : []; + + // Fetch bibliotecas (solo si esta base las soporta) + const libraries = config.api.hasLibraries + ? await fetchLibraries(config.id) + : []; + + this.setState({ + tags, + hstTags, + groups, + libraries, + library: 'all', + libraryMembers: new Set(), + group: 'all' + }); + } + + render(): void { + const state = this.getState(); + const config = this.getConfig(); + + // Verificar que la vista está soportada + if (!this.isViewSupported(state.view)) { + // Cambiar a vista por defecto + const defaultView = config.defaultView as ViewType; + this.setState({ view: defaultView }); + return; + } + + // Unmount current view + this.currentView?.unmount(); + + // Clear container + this.ctx.container.innerHTML = ''; + this.ctx.container.className = `content-area ${state.view}-view`; + + // Mount new view + switch (state.view) { + case 'grid': + this.currentView = new GridView(this.ctx.container, this.ctx.store, this.ctx.showDetail); + this.currentView.mount(); + break; + + case 'tree': + if (config.views.tree) { + this.currentView = new TreeView(this.ctx.container, this.ctx.store, this.ctx.showDetail); + this.currentView.mount(); + } + break; + + case 'graph': + if (config.views.graph) { + this.currentView = new GraphView(this.ctx.container, this.ctx.store, this.ctx.showDetail); + (this.currentView as GraphView).mount(); + } + break; + } + } + + renderSidebar(): void { + const container = this.ctx.leftPanel; + const state = this.getState(); + const config = this.getConfig(); + + // Si es vista de grafo, mostrar opciones de grafo + if (state.view === 'graph' && config.views.graph) { + container.classList.add('graph-mode'); + this.renderGraphOptions(container); + return; + } + + container.classList.remove('graph-mode'); + + // Si no tiene bibliotecas, vaciar sidebar + if (!config.api.hasLibraries) { + container.innerHTML = ''; + return; + } + + // Ordenar bibliotecas alfabéticamente + const sortedLibs = [...state.libraries].sort((a, b) => { + const nameA = a.name || a.name_es || a.alias || a.ref || ''; + const nameB = b.name || b.name_es || b.alias || b.ref || ''; + return nameA.localeCompare(nameB); + }); + + // Renderizar bibliotecas (simple - sin config por ahora) + container.innerHTML = ` +
+ ALL +
+ ${sortedLibs.map(lib => { + const icon = lib.img_thumb_url || lib.icon_url || ''; + const name = lib.name || lib.name_es || lib.alias || lib.ref || lib.mrf.slice(0, 6); + return ` +
+ ${icon ? `` : ''} + ${name.slice(0, 8)} +
+ `; + }).join('')} + `; + + // Bind library clicks + delegateEvent(container, '.lib-icon', 'click', async (_, target) => { + const library = target.dataset.lib || 'all'; + + if (library === 'all') { + this.setState({ library: 'all', libraryMembers: new Set() }); + } else { + const currentBase = this.getState().base; + const members = await fetchLibraryMembers(library, currentBase); + this.setState({ library, libraryMembers: new Set(members) }); + } + + this.renderSidebar(); + this.render(); + }); + } + + renderGroupsBar(): void { + const container = this.ctx.groupsBar; + const state = this.getState(); + const config = this.getConfig(); + + // Si no tiene grupos, vaciar + if (!config.api.hasGroups) { + container.innerHTML = ''; + return; + } + + // Usar hstTags para resolución de nombres + const nameMap = createNameMap(state.hstTags, state.lang); + + // Contar tags por grupo + const counts = new Map(); + state.tags.forEach(tag => { + const group = tag.set_hst || 'sin-grupo'; + counts.set(group, (counts.get(group) || 0) + 1); + }); + + // Ordenar por count y tomar top 20 + const sorted = Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20); + + container.innerHTML = ` + + ${sorted.map(([groupMrf, count]) => { + const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap); + return ` + + `; + }).join('')} + `; + + // Bind group clicks + delegateEvent(container, '.group-btn', 'click', (_, target) => { + const group = target.dataset.group || 'all'; + this.setState({ group }); + this.renderGroupsBar(); + this.render(); + }); + } + + private renderGraphOptions(container: HTMLElement): void { + // TODO: Extraer a componente separado + const state = this.getState(); + const { graphSettings, tags, graphEdges } = state; + + container.innerHTML = ` +
+
+
+ Nodos + ${tags.length} +
+
+ Edges + ${graphEdges.length} +
+
+
+
Visualización
+ + +
+
+ Nodo + ${graphSettings.nodeSize}px +
+ +
+
+
+ Distancia + ${graphSettings.linkDist}px +
+ +
+
+
+ `; + + this.bindGraphOptionEvents(container); + } + + private bindGraphOptionEvents(container: HTMLElement): void { + // Show images checkbox + const showImgCb = container.querySelector('#graph-show-img'); + showImgCb?.addEventListener('change', () => { + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, showImg: showImgCb.checked } + }); + this.render(); + }); + + // Show labels checkbox + const showLblCb = container.querySelector('#graph-show-lbl'); + showLblCb?.addEventListener('change', () => { + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, showLbl: showLblCb.checked } + }); + this.render(); + }); + + // Node size slider + const nodeSizeSlider = container.querySelector('#graph-node-size'); + const nodeSizeVal = container.querySelector('#node-size-val'); + nodeSizeSlider?.addEventListener('input', () => { + const size = parseInt(nodeSizeSlider.value, 10); + if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`; + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, nodeSize: size } + }); + this.render(); + }); + + // Link distance slider + const linkDistSlider = container.querySelector('#graph-link-dist'); + const linkDistVal = container.querySelector('#link-dist-val'); + linkDistSlider?.addEventListener('input', () => { + const dist = parseInt(linkDistSlider.value, 10); + if (linkDistVal) linkDistVal.textContent = `${dist}px`; + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, linkDist: dist } + }); + this.render(); + }); + } +} + +export default StandardModule; diff --git a/deck-frontend/backups/20260113_211524/src/modules/standard/index.ts b/deck-frontend/backups/20260113_211524/src/modules/standard/index.ts new file mode 100644 index 0000000..d1cb866 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/modules/standard/index.ts @@ -0,0 +1 @@ +export { StandardModule, default } from './StandardModule.ts'; diff --git a/deck-frontend/backups/20260113_211524/src/router/index.ts b/deck-frontend/backups/20260113_211524/src/router/index.ts new file mode 100644 index 0000000..e6dd489 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/router/index.ts @@ -0,0 +1 @@ +export { Router } from './router.ts'; diff --git a/deck-frontend/backups/20260113_211524/src/router/router.ts b/deck-frontend/backups/20260113_211524/src/router/router.ts new file mode 100644 index 0000000..e49ab07 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/router/router.ts @@ -0,0 +1,68 @@ +import type { Store } from '@/state/store.ts'; +import type { AppState, BaseType, ViewType } from '@/types/index.ts'; + +const VALID_BASES: BaseType[] = [ + 'hst', 'flg', 'itm', 'loc', 'ply', // Taxonomía + 'mst', 'bck', // Maestros + 'mth', 'atc', // Registro + 'mail', 'chat', // Comunicación + 'key', 'mindlink' // Servicios +]; +const VALID_VIEWS: ViewType[] = ['grid', 'tree', 'graph']; + +export class Router { + private store: Store; + private onNavigate: () => void; + + constructor(store: Store, onNavigate: () => void) { + this.store = store; + this.onNavigate = onNavigate; + + window.addEventListener('hashchange', () => this.handleHashChange()); + } + + parseHash(): void { + const hash = window.location.hash + .replace(/^#\/?/, '') + .replace(/\/?$/, '') + .split('/') + .filter(Boolean); + + const state = this.store.getState(); + let base = state.base; + let view = state.view; + + if (hash[0] && VALID_BASES.includes(hash[0].toLowerCase() as BaseType)) { + base = hash[0].toLowerCase() as BaseType; + } + + if (hash[1] && VALID_VIEWS.includes(hash[1].toLowerCase() as ViewType)) { + view = hash[1].toLowerCase() as ViewType; + } + + this.store.setState({ base, view }); + } + + updateHash(): void { + const state = this.store.getState(); + const parts: string[] = [state.base]; + if (state.view !== 'grid') { + parts.push(state.view); + } + window.location.hash = '/' + parts.join('/') + '/'; + } + + private handleHashChange(): void { + this.parseHash(); + this.onNavigate(); + } + + navigate(base?: BaseType, view?: ViewType): void { + const state = this.store.getState(); + this.store.setState({ + base: base ?? state.base, + view: view ?? state.view + }); + this.updateHash(); + } +} diff --git a/deck-frontend/backups/20260113_211524/src/state/index.ts b/deck-frontend/backups/20260113_211524/src/state/index.ts new file mode 100644 index 0000000..5d3a5c7 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/state/index.ts @@ -0,0 +1,35 @@ +import { createStore } from './store.ts'; +import type { AppState, EdgeType } from '@/types/index.ts'; +import { EDGE_COLORS } from '@/config/index.ts'; + +const initialState: AppState = { + base: 'hst', + lang: 'es', + view: 'grid', + search: '', + group: 'all', + library: 'all', + libraryMembers: new Set(), + selectionMode: false, + selected: new Set(), + selectedTag: null, + tags: [], + hstTags: [], + groups: [], + libraries: [], + graphEdges: [], + treeEdges: [], + graphFilters: { + cats: new Set(['hst'] as const), + edges: new Set(Object.keys(EDGE_COLORS) as EdgeType[]) + }, + graphSettings: { + nodeSize: 20, + linkDist: 80, + showImg: true, + showLbl: true + } +}; + +export const store = createStore(initialState); +export { createStore } from './store.ts'; diff --git a/deck-frontend/backups/20260113_211524/src/state/store.ts b/deck-frontend/backups/20260113_211524/src/state/store.ts new file mode 100644 index 0000000..865b837 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/state/store.ts @@ -0,0 +1,27 @@ +type Listener = (state: T, prevState: T) => void; + +export interface Store { + getState: () => Readonly; + setState: (partial: Partial) => void; + subscribe: (listener: Listener) => () => void; +} + +export function createStore(initialState: T): Store { + let state = { ...initialState }; + const listeners = new Set>(); + + return { + getState: (): Readonly => state, + + setState: (partial: Partial): void => { + const prevState = state; + state = { ...state, ...partial }; + listeners.forEach(fn => fn(state, prevState)); + }, + + subscribe: (listener: Listener): (() => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + } + }; +} diff --git a/deck-frontend/backups/20260113_211524/src/styles/main.css b/deck-frontend/backups/20260113_211524/src/styles/main.css new file mode 100644 index 0000000..84d3791 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/styles/main.css @@ -0,0 +1,701 @@ +/* === RESET & VARIABLES === */ +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #0a0a0f; + --bg-secondary: #12121a; + --bg-card: #1a1a24; + --border: #2a2a3a; + --text: #e0e0e0; + --text-muted: #888; + --accent: #7c8aff; + --card-width: 176px; + --card-img-height: 176px; +} + +html, body { height: 100%; overflow: hidden; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: var(--bg-secondary); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; } +::-webkit-scrollbar-thumb:hover { background: #444; } + +/* === APP LAYOUT === */ +.app { display: flex; flex-direction: column; height: 100vh; } + +/* === TOPBAR === */ +.topbar { + height: 50px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 16px; + gap: 12px; +} +.topbar-left { display: flex; align-items: center; gap: 10px; } +.topbar-center { flex: 1; display: flex; justify-content: center; gap: 16px; } +.topbar-right { display: flex; align-items: center; gap: 10px; } +.logo { font-weight: 700; font-size: 1.2em; color: var(--accent); letter-spacing: 1px; } + +/* === BUTTONS === */ +.btn { + padding: 7px 14px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.8em; + font-weight: 500; + transition: all 0.15s ease; +} +.btn:hover { border-color: var(--accent); color: var(--text); } +.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-sm { padding: 5px 10px; font-size: 0.75em; } + +.search-input { + width: 300px; + padding: 9px 14px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 0.9em; +} +.search-input:focus { outline: none; border-color: var(--accent); } +.search-input::placeholder { color: var(--text-muted); } + +.base-buttons { display: flex; gap: 2px; background: var(--bg-card); border-radius: 6px; padding: 3px; } +.base-btn { + padding: 6px 14px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.8em; + font-weight: 600; + transition: all 0.15s ease; +} +.base-btn:hover { color: var(--text); } +.base-btn.active { background: var(--accent); color: #fff; } + +/* === VIEW BAR === */ +.view-bar { + height: 40px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; +} +.view-bar-spacer { width: 120px; } + +/* === SEL/GET GROUP === */ +.sel-group { + display: flex; + align-items: center; + gap: 2px; + background: var(--bg-card); + border-radius: 6px; + padding: 3px; +} +.sel-btn { + padding: 5px 12px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.75em; + font-weight: 600; + transition: all 0.15s ease; +} +.sel-btn:hover { color: var(--text); background: rgba(255,255,255,0.05); } +.sel-btn.active { background: var(--accent); color: #fff; } +#sel-count { + font-size: 0.7em; + color: var(--accent); + margin-left: 6px; + font-weight: 600; +} + +/* === GROUPS BAR === */ +.groups-bar { + height: 44px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 16px; + gap: 8px; + overflow-x: auto; +} +.group-btn { + padding: 6px 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 20px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.75em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + transition: all 0.15s ease; +} +.group-btn:hover { border-color: var(--accent); color: var(--text); } +.group-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +/* === MAIN LAYOUT === */ +.main-layout { display: flex; flex: 1; overflow: hidden; } + +/* === LEFT PANEL === */ +.left-panel { + width: 84px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + overflow-y: auto; + padding: 10px 6px; + flex-shrink: 0; +} +.lib-icon { + width: 68px; + height: 68px; + margin: 6px auto; + border-radius: 10px; + background: var(--bg-card); + border: 2px solid transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + transition: all 0.15s ease; + overflow: hidden; +} +.lib-icon:hover { border-color: var(--accent); } +.lib-icon.active { border-color: var(--accent); background: rgba(124, 138, 255, 0.15); } +.lib-icon img { width: 42px; height: 42px; object-fit: cover; border-radius: 6px; } +.lib-icon span { + font-size: 0.6em; + color: var(--text-muted); + margin-top: 4px; + text-align: center; + max-width: 60px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* === CENTER PANEL === */ +.center-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; } + +/* === VIEW TABS === */ +.view-tabs { display: flex; gap: 6px; } +.view-tab { + padding: 7px 20px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.85em; + font-weight: 500; + transition: all 0.15s ease; +} +.view-tab:hover { color: var(--text); background: var(--bg-card); } +.view-tab.active { background: var(--accent); color: #fff; } + +/* === CONTENT AREA === */ +.content-area { flex: 1; overflow: hidden; position: relative; } + +/* === GRID VIEW === */ +.grid-view { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 16px; + padding: 20px; + overflow-y: auto; + height: 100%; +} + +.card { + width: var(--card-width); + flex-shrink: 0; + flex-grow: 0; + background: var(--bg-card); + border-radius: 10px; + border: 1px solid var(--border); + overflow: hidden; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; + position: relative; +} +.card:hover { + border-color: var(--accent); + transform: translateY(-3px); + box-shadow: 0 8px 20px rgba(0,0,0,0.3); +} +.card.selected { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(124, 138, 255, 0.4); +} + +.card-checkbox { + position: absolute; + top: 10px; + left: 10px; + width: 24px; + height: 24px; + border-radius: 6px; + background: rgba(0,0,0,0.7); + border: 2px solid var(--border); + display: none; + align-items: center; + justify-content: center; + z-index: 5; + transition: all 0.15s ease; +} +.card-checkbox.visible { display: flex; } +.card-checkbox.checked { background: var(--accent); border-color: var(--accent); } +.card-checkbox.checked::after { content: "\2713"; color: #fff; font-size: 14px; font-weight: bold; } + +.card-image { + width: var(--card-width); + height: var(--card-img-height); + background: linear-gradient(145deg, #1a1a24 0%, #0a0a0f 100%); + position: relative; + overflow: hidden; +} + +.card-placeholder { + width: var(--card-width); + height: var(--card-img-height); + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5em; + font-weight: 700; + color: var(--accent); + opacity: 0.5; + text-transform: uppercase; + background: linear-gradient(145deg, #1a1a24 0%, #0a0a0f 100%); +} + +.card-img { + width: var(--card-width); + height: var(--card-img-height); + object-fit: cover; +} + +.card-body { padding: 12px; } +.card-ref { + font-size: 0.75em; + color: var(--accent); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.card-name { + font-size: 0.85em; + color: var(--text); + margin-top: 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; +} + +/* === TREE VIEW === */ +.tree-view { + padding: 20px; + overflow-y: auto; + height: 100%; +} +.tree-group { margin-bottom: 12px; } +.tree-header { + display: flex; + align-items: center; + padding: 10px 12px; + cursor: pointer; + border-radius: 8px; + background: var(--bg-card); + transition: background 0.15s ease; +} +.tree-header:hover { background: var(--bg-secondary); } +.tree-toggle { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 1em; + font-weight: bold; + flex-shrink: 0; +} +.tree-group-name { flex: 1; font-weight: 500; } +.tree-count { + font-size: 0.75em; + color: var(--text-muted); + background: var(--bg-secondary); + padding: 4px 10px; + border-radius: 12px; +} +.tree-items { display: none; margin-left: 28px; margin-top: 4px; } +.tree-items.expanded { display: block; } +.tree-item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + border-radius: 6px; + margin: 3px 0; + gap: 10px; + transition: background 0.15s ease; +} +.tree-item:hover { background: var(--bg-card); } +.tree-item.selected { background: rgba(124,138,255,0.15); } +.tree-img { + width: 32px; + height: 32px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; + background: var(--bg-card); +} +.tree-placeholder { + width: 32px; + height: 32px; + border-radius: 6px; + background: var(--bg-card); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9em; + font-weight: 600; + color: var(--accent); +} +.tree-name { font-size: 0.9em; } + +/* === GRAPH VIEW === */ +.graph-view { + width: 100%; + height: 100%; + position: relative; +} +.graph-view svg { width: 100%; height: 100%; display: block; background: var(--bg); } +.node { cursor: pointer; } +.node text { fill: var(--text-muted); pointer-events: none; font-size: 11px; } +.node.selected circle { stroke: var(--accent); stroke-width: 4; } +.link { stroke-opacity: 0.5; } + +/* === DETAIL PANEL === */ +.detail-panel { + width: 0; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + overflow-y: auto; + overflow-x: hidden; + transition: width 0.3s ease; + flex-shrink: 0; +} +.detail-panel.open { width: 360px; } + +.detail-header { + position: relative; + width: 100%; + height: 220px; + background: linear-gradient(145deg, var(--bg-card) 0%, var(--bg) 100%); + overflow: hidden; +} +.detail-placeholder { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 5em; + font-weight: 700; + color: var(--accent); + opacity: 0.4; + text-transform: uppercase; +} +.detail-img { + width: 100%; + height: 100%; + object-fit: cover; +} +.detail-close { + position: absolute; + top: 12px; + right: 12px; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(0,0,0,0.7); + border: none; + color: #fff; + cursor: pointer; + font-size: 20px; + z-index: 5; + transition: background 0.15s ease; +} +.detail-close:hover { background: rgba(0,0,0,0.9); } + +.detail-body { padding: 20px; } +.detail-ref { + font-size: 1.2em; + color: var(--accent); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; +} +.detail-mrf { + font-size: 0.7em; + color: var(--text-muted); + margin-top: 8px; + font-family: monospace; + word-break: break-all; + cursor: pointer; + padding: 8px 10px; + background: var(--bg-card); + border-radius: 6px; + transition: color 0.15s ease; +} +.detail-mrf:hover { color: var(--accent); } +.detail-name { font-size: 1.3em; color: var(--text); margin-top: 16px; font-weight: 500; } +.detail-desc { font-size: 0.9em; color: var(--text-muted); margin-top: 12px; line-height: 1.7; } + +.detail-section { margin-top: 24px; } +.detail-section h4 { + font-size: 0.75em; + color: var(--text-muted); + text-transform: uppercase; + margin-bottom: 12px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.chip-list { display: flex; flex-wrap: wrap; gap: 8px; } +.tag-chip { + padding: 7px 12px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.8em; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s ease; +} +.tag-chip:hover { border-color: var(--accent); color: var(--text); } + +/* === TOAST === */ +.toast { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: var(--accent); + color: #fff; + padding: 14px 28px; + border-radius: 10px; + font-size: 0.9em; + font-weight: 500; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 1000; + pointer-events: none; +} +.toast.show { opacity: 1; } + +/* === MODAL === */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.85); + display: none; + align-items: center; + justify-content: center; + z-index: 100; +} +.modal.open { display: flex; } +.modal-content { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 14px; + width: 90%; + max-width: 620px; + max-height: 80vh; + overflow: hidden; +} +.modal-header { + padding: 18px 22px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} +.modal-header h3 { font-size: 1.15em; color: var(--text); font-weight: 600; } +.modal-close { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1.5em; + padding: 4px 8px; +} +.modal-close:hover { color: var(--text); } +.modal-body { padding: 22px; overflow-y: auto; max-height: calc(80vh - 65px); } +.api-item { margin-bottom: 18px; } +.api-endpoint { + font-family: monospace; + font-size: 0.9em; + color: var(--accent); + background: var(--bg-card); + padding: 12px 14px; + border-radius: 8px; +} +.api-desc { font-size: 0.85em; color: var(--text-muted); margin-top: 8px; } + +/* === EMPTY STATE === */ +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + gap: 16px; + padding: 40px; +} + +/* === LOADING === */ +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + gap: 14px; + font-size: 1em; +} +.loading::after { + content: ""; + width: 28px; + height: 28px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* === SELECT === */ +select { + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-card); + color: var(--text); + font-size: 0.8em; + cursor: pointer; +} +select:focus { outline: none; border-color: var(--accent); } + +/* === GRAPH OPTIONS PANEL === */ +.graph-options { + padding: 10px; + overflow-y: auto; + width: 180px; +} +.graph-section { + margin-bottom: 16px; +} +.graph-section-title { + font-size: 0.7em; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} +.graph-stat { + display: flex; + justify-content: space-between; + font-size: 0.75em; + color: var(--text-muted); + margin-bottom: 4px; +} +.graph-stat-value { + color: var(--text); + font-weight: 600; +} +.graph-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.75em; + color: var(--text-muted); + margin-bottom: 6px; + cursor: pointer; +} +.graph-checkbox:hover { color: var(--text); } +.graph-checkbox input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--accent); + cursor: pointer; +} +.graph-checkbox .color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 4px; +} +.graph-slider { + margin-bottom: 12px; +} +.graph-slider-label { + display: flex; + justify-content: space-between; + font-size: 0.7em; + color: var(--text-muted); + margin-bottom: 4px; +} +.graph-slider-value { + color: var(--text); + font-weight: 600; +} +.graph-slider input[type="range"] { + width: 100%; + height: 4px; + background: var(--border); + border-radius: 2px; + -webkit-appearance: none; + cursor: pointer; +} +.graph-slider input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; +} +.left-panel.graph-mode { + width: 180px; +} diff --git a/deck-frontend/backups/20260113_211524/src/types/graph.ts b/deck-frontend/backups/20260113_211524/src/types/graph.ts new file mode 100644 index 0000000..ceaaa4b --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/types/graph.ts @@ -0,0 +1,37 @@ +export type EdgeType = + | 'relation' + | 'specialization' + | 'mirror' + | 'dependency' + | 'sequence' + | 'composition' + | 'hierarchy' + | 'library' + | 'contextual' + | 'association'; + +export type CategoryKey = 'hst' | 'spe' | 'vue' | 'vsn' | 'msn' | 'flg'; + +export interface GraphEdge { + mrf_a: string; + mrf_b: string; + edge_type: EdgeType; + weight?: number; +} + +export interface TreeEdge { + mrf_parent: string; + mrf_child: string; +} + +export interface GraphNode { + id: string; + ref: string; + name: string; + img: string; + cat: CategoryKey; + x?: number; + y?: number; + fx?: number | null; + fy?: number | null; +} diff --git a/deck-frontend/backups/20260113_211524/src/types/index.ts b/deck-frontend/backups/20260113_211524/src/types/index.ts new file mode 100644 index 0000000..5d577bd --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/types/index.ts @@ -0,0 +1,16 @@ +export type { Tag, Group, Library, ChildTag, RelatedTag } from './tag.ts'; +export type { + EdgeType, + CategoryKey, + GraphEdge, + TreeEdge, + GraphNode +} from './graph.ts'; +export type { + ViewType, + BaseType, + LangType, + GraphFilters, + GraphSettings, + AppState +} from './state.ts'; diff --git a/deck-frontend/backups/20260113_211524/src/types/state.ts b/deck-frontend/backups/20260113_211524/src/types/state.ts new file mode 100644 index 0000000..0fc8a07 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/types/state.ts @@ -0,0 +1,53 @@ +import type { Tag, Group, Library } from './tag.ts'; +import type { GraphEdge, TreeEdge, CategoryKey, EdgeType } from './graph.ts'; + +export type ViewType = 'grid' | 'tree' | 'graph'; +export type BaseType = + | 'hst' | 'flg' | 'itm' | 'loc' | 'ply' // Taxonomía (public) + | 'mth' | 'atc' // Registro (secretaria_clara, production_alfred) + | 'mst' | 'bck' // Maestros (secretaria_clara) + | 'mail' | 'chat' // Comunicación (mail_manager, context_manager) + | 'key' | 'mindlink'; // Servicios +export type LangType = 'es' | 'en' | 'ch'; + +export interface GraphFilters { + cats: Set; + edges: Set; +} + +export interface GraphSettings { + nodeSize: number; + linkDist: number; + showImg: boolean; + showLbl: boolean; +} + +export interface AppState { + // Navigation + base: BaseType; + lang: LangType; + view: ViewType; + + // Filters + search: string; + group: string; + library: string; + libraryMembers: Set; + + // Selection + selectionMode: boolean; + selected: Set; + selectedTag: Tag | null; + + // Data + tags: Tag[]; + hstTags: Tag[]; // HST tags for group name resolution + groups: Group[]; + libraries: Library[]; + graphEdges: GraphEdge[]; + treeEdges: TreeEdge[]; + + // Graph-specific + graphFilters: GraphFilters; + graphSettings: GraphSettings; +} diff --git a/deck-frontend/backups/20260113_211524/src/types/tag.ts b/deck-frontend/backups/20260113_211524/src/types/tag.ts new file mode 100644 index 0000000..425df07 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/types/tag.ts @@ -0,0 +1,42 @@ +export interface Tag { + mrf: string; + ref: string; + name_es?: string; + name_en?: string; + name_ch?: string; + txt?: string; + alias?: string; + set_hst?: string; + img_url?: string; + img_thumb_url?: string; +} + +export interface Group { + mrf: string; + ref: string; + name_es?: string; + name_en?: string; +} + +export interface Library { + mrf: string; + ref?: string; + name?: string; + name_es?: string; + name_en?: string; + alias?: string; + icon_url?: string; + img_thumb_url?: string; + member_count?: number; +} + +export interface ChildTag { + mrf: string; + ref?: string; + alias?: string; + name_es?: string; +} + +export interface RelatedTag extends ChildTag { + edge_type: string; +} diff --git a/deck-frontend/backups/20260113_211524/src/utils/clipboard.ts b/deck-frontend/backups/20260113_211524/src/utils/clipboard.ts new file mode 100644 index 0000000..ab117d1 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/utils/clipboard.ts @@ -0,0 +1,14 @@ +import { toast } from './toast.ts'; + +export async function copyToClipboard(text: string, message?: string): Promise { + try { + await navigator.clipboard.writeText(text); + toast(message || 'Copiado'); + } catch { + toast('Error al copiar'); + } +} + +export function copyMrf(mrf: string): void { + copyToClipboard(mrf, `MRF copiado: ${mrf.slice(0, 8)}...`); +} diff --git a/deck-frontend/backups/20260113_211524/src/utils/dom.ts b/deck-frontend/backups/20260113_211524/src/utils/dom.ts new file mode 100644 index 0000000..50cfa99 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/utils/dom.ts @@ -0,0 +1,44 @@ +export const $ = ( + selector: string, + parent: ParentNode = document +): T | null => parent.querySelector(selector); + +export const $$ = ( + selector: string, + parent: ParentNode = document +): T[] => Array.from(parent.querySelectorAll(selector)); + +export function createElement( + tag: K, + attrs?: Record, + children?: (HTMLElement | string)[] +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + if (attrs) { + Object.entries(attrs).forEach(([key, value]) => { + if (key === 'className') el.className = value; + else if (key.startsWith('data-')) el.setAttribute(key, value); + else el.setAttribute(key, value); + }); + } + if (children) { + children.forEach(child => { + el.append(typeof child === 'string' ? child : child); + }); + } + return el; +} + +export function delegateEvent( + container: HTMLElement, + selector: string, + eventType: string, + handler: (event: T, target: HTMLElement) => void +): void { + container.addEventListener(eventType, (event) => { + const target = (event.target as HTMLElement).closest(selector); + if (target && container.contains(target)) { + handler(event as T, target); + } + }); +} diff --git a/deck-frontend/backups/20260113_211524/src/utils/filters.ts b/deck-frontend/backups/20260113_211524/src/utils/filters.ts new file mode 100644 index 0000000..b192126 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/utils/filters.ts @@ -0,0 +1,39 @@ +import type { Tag, LangType } from '@/types/index.ts'; +import { getName } from './i18n.ts'; + +export interface FilterOptions { + search: string; + group: string; + library: string; + libraryMembers: Set; + lang: LangType; +} + +export function filterTags(tags: Tag[], options: FilterOptions): Tag[] { + const { search, group, library, libraryMembers, lang } = options; + const q = search.toLowerCase(); + + return tags.filter(tag => { + // Library filter + if (library !== 'all' && !libraryMembers.has(tag.mrf)) { + return false; + } + + // Group filter + if (group !== 'all' && tag.set_hst !== group) { + return false; + } + + // Search filter + if (q) { + const name = getName(tag, lang).toLowerCase(); + const ref = (tag.ref || '').toLowerCase(); + const alias = (tag.alias || '').toLowerCase(); + if (!name.includes(q) && !ref.includes(q) && !alias.includes(q)) { + return false; + } + } + + return true; + }); +} diff --git a/deck-frontend/backups/20260113_211524/src/utils/i18n.ts b/deck-frontend/backups/20260113_211524/src/utils/i18n.ts new file mode 100644 index 0000000..1d1b8d2 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/utils/i18n.ts @@ -0,0 +1,42 @@ +import type { Tag, LangType } from '@/types/index.ts'; + +export function getName(tag: Tag, lang: LangType): string { + if (lang === 'es' && tag.name_es) return tag.name_es; + if (lang === 'en' && tag.name_en) return tag.name_en; + if (lang === 'ch' && tag.name_ch) return tag.name_ch; + return tag.name_es || tag.name_en || tag.alias || tag.ref || tag.mrf.slice(0, 8); +} + +// Create a map of mrf -> display name for resolving groups +export function createNameMap(tags: Tag[], lang: LangType): Map { + const map = new Map(); + tags.forEach(tag => { + map.set(tag.mrf, getName(tag, lang)); + }); + return map; +} + +// Resolve a group mrf to its display name +export function resolveGroupName(mrf: string | undefined, nameMap: Map): string { + if (!mrf) return 'Sin grupo'; + return nameMap.get(mrf) || mrf.slice(0, 8); +} + +const ATC_BASE = 'https://atc.tzzrdeck.me'; + +function resolveImgUrl(url: string | undefined): string { + if (!url) return ''; + // Relative paths (e.g., "thumbs/xxx.png") need ATC base + if (url && !url.startsWith('http')) { + return `${ATC_BASE}/${url}`; + } + return url; +} + +export function getImg(tag: Tag): string { + return resolveImgUrl(tag.img_thumb_url); +} + +export function getFullImg(tag: Tag): string { + return resolveImgUrl(tag.img_url) || resolveImgUrl(tag.img_thumb_url); +} diff --git a/deck-frontend/backups/20260113_211524/src/utils/index.ts b/deck-frontend/backups/20260113_211524/src/utils/index.ts new file mode 100644 index 0000000..a0131b6 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/utils/index.ts @@ -0,0 +1,5 @@ +export { $, $$, createElement, delegateEvent } from './dom.ts'; +export { getName, getImg, getFullImg, createNameMap, resolveGroupName } from './i18n.ts'; +export { filterTags, type FilterOptions } from './filters.ts'; +export { copyToClipboard, copyMrf } from './clipboard.ts'; +export { toast } from './toast.ts'; diff --git a/deck-frontend/backups/20260113_211524/src/utils/toast.ts b/deck-frontend/backups/20260113_211524/src/utils/toast.ts new file mode 100644 index 0000000..e5258ee --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/utils/toast.ts @@ -0,0 +1,21 @@ +let toastEl: HTMLElement | null = null; +let toastTimeout: number | null = null; + +export function toast(message: string, duration = 2000): void { + if (!toastEl) { + toastEl = document.createElement('div'); + toastEl.className = 'toast'; + document.body.appendChild(toastEl); + } + + if (toastTimeout) { + clearTimeout(toastTimeout); + } + + toastEl.textContent = message; + toastEl.classList.add('show'); + + toastTimeout = window.setTimeout(() => { + toastEl?.classList.remove('show'); + }, duration); +} diff --git a/deck-frontend/backups/20260113_211524/src/views/DetailPanel/DetailPanel.ts b/deck-frontend/backups/20260113_211524/src/views/DetailPanel/DetailPanel.ts new file mode 100644 index 0000000..70833bf --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/views/DetailPanel/DetailPanel.ts @@ -0,0 +1,115 @@ +import { View } from '../View.ts'; +import { getName, getFullImg, copyMrf, delegateEvent } from '@/utils/index.ts'; +import { fetchChildren, fetchRelated } from '@/api/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, Tag } from '@/types/index.ts'; + +export class DetailPanel extends View { + private panelEl: HTMLElement; + + constructor( + container: HTMLElement, + store: Store + ) { + super(container, store); + this.panelEl = container; + } + + async showDetail(mrf: string): Promise { + const state = this.getState(); + const tag = state.tags.find(t => t.mrf === mrf); + if (!tag) return; + + this.setState({ selectedTag: tag }); + this.panelEl.classList.add('open'); + await this.renderDetail(tag); + } + + close(): void { + this.panelEl.classList.remove('open'); + this.setState({ selectedTag: null }); + } + + render(): void { + const state = this.getState(); + if (state.selectedTag) { + this.renderDetail(state.selectedTag); + } + } + + private async renderDetail(tag: Tag): Promise { + const state = this.getState(); + const img = getFullImg(tag); + const name = getName(tag, state.lang); + + this.panelEl.innerHTML = ` +
+ ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 2) || 'T'}
` + } + +
+
+
${tag.ref || ''}
+
${tag.mrf}
+
${name}
+
${tag.txt || tag.alias || ''}
+ + +
+ `; + + this.bindDetailEvents(); + await this.loadRelations(tag.mrf); + } + + private bindDetailEvents(): void { + const closeBtn = this.panelEl.querySelector('.detail-close'); + closeBtn?.addEventListener('click', () => this.close()); + + const mrfEl = this.panelEl.querySelector('.detail-mrf'); + mrfEl?.addEventListener('click', () => { + const mrf = (mrfEl as HTMLElement).dataset.mrf; + if (mrf) copyMrf(mrf); + }); + + delegateEvent(this.panelEl, '.tag-chip', 'click', (_, target) => { + const mrf = target.dataset.mrf; + if (mrf) this.showDetail(mrf); + }); + } + + private async loadRelations(mrf: string): Promise { + const [children, related] = await Promise.all([ + fetchChildren(mrf), + fetchRelated(mrf) + ]); + + const childrenSection = this.panelEl.querySelector('#children-section') as HTMLElement; + const childrenList = this.panelEl.querySelector('#children-list') as HTMLElement; + if (children.length > 0) { + childrenSection.style.display = 'block'; + childrenList.innerHTML = children.map(c => { + const label = c.name_es || c.alias || c.ref || c.mrf.slice(0, 8); + return `${label}`; + }).join(''); + } + + const relatedSection = this.panelEl.querySelector('#related-section') as HTMLElement; + const relatedList = this.panelEl.querySelector('#related-list') as HTMLElement; + if (related.length > 0) { + relatedSection.style.display = 'block'; + relatedList.innerHTML = related.map(r => { + const label = r.name_es || r.alias || r.ref || r.mrf.slice(0, 8); + return `${label}`; + }).join(''); + } + } +} diff --git a/deck-frontend/backups/20260113_211524/src/views/GraphView/GraphView.ts b/deck-frontend/backups/20260113_211524/src/views/GraphView/GraphView.ts new file mode 100644 index 0000000..b708ca9 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/views/GraphView/GraphView.ts @@ -0,0 +1,249 @@ +import { View } from '../View.ts'; +import { filterTags, getName, getImg } from '@/utils/index.ts'; +import { fetchGraphEdges, fetchTreeEdges } from '@/api/index.ts'; +import { CATS, EDGE_COLORS } from '@/config/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, GraphNode, CategoryKey, EdgeType } from '@/types/index.ts'; + +type D3Module = typeof import('d3'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type D3Selection = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type D3Simulation = any; + +export class GraphView extends View { + private d3: D3Module | null = null; + private simulation: D3Simulation | null = null; + private showDetail: (mrf: string) => void; + + constructor( + container: HTMLElement, + store: Store, + showDetail: (mrf: string) => void + ) { + super(container, store); + this.showDetail = showDetail; + } + + async mount(): Promise { + this.container.innerHTML = '
Cargando grafo...
'; + + // Lazy load D3 + if (!this.d3) { + this.d3 = await import('d3'); + } + + // Load graph data + const state = this.getState(); + if (state.graphEdges.length === 0) { + const [graphEdges, treeEdges] = await Promise.all([ + fetchGraphEdges(), + fetchTreeEdges() + ]); + this.store.setState({ graphEdges, treeEdges }); + } + + this.render(); + } + + render(): void { + if (!this.d3) return; + const d3 = this.d3; + const state = this.getState(); + + // Build nodes from filtered tags + const filtered = filterTags(state.tags, { + search: state.search, + group: state.group, + library: state.library, + libraryMembers: state.libraryMembers, + lang: state.lang + }); + + const nodeMap = new Map(); + filtered.forEach(tag => { + nodeMap.set(tag.mrf, { + id: tag.mrf, + ref: tag.alias || tag.ref || tag.mrf.slice(0, 8), + name: getName(tag, state.lang), + img: getImg(tag), + cat: 'hst' as CategoryKey + }); + }); + + // Build edges + interface GraphLink { + source: string | GraphNode; + target: string | GraphNode; + type: EdgeType; + weight: number; + } + const edges: GraphLink[] = []; + + state.graphEdges.forEach(e => { + if (nodeMap.has(e.mrf_a) && nodeMap.has(e.mrf_b)) { + if (state.graphFilters.edges.has(e.edge_type)) { + edges.push({ + source: e.mrf_a, + target: e.mrf_b, + type: e.edge_type, + weight: e.weight || 1 + }); + } + } + }); + + state.treeEdges.forEach(e => { + if (nodeMap.has(e.mrf_parent) && nodeMap.has(e.mrf_child)) { + if (state.graphFilters.edges.has('hierarchy')) { + edges.push({ + source: e.mrf_parent, + target: e.mrf_child, + type: 'hierarchy', + weight: 1 + }); + } + } + }); + + const nodes = Array.from(nodeMap.values()); + + if (nodes.length === 0) { + this.container.innerHTML = '
Sin nodos para mostrar
'; + return; + } + + // Clear and create SVG + this.container.innerHTML = ''; + const width = this.container.clientWidth; + const height = this.container.clientHeight || 600; + + const svg: D3Selection = d3.select(this.container) + .append('svg') + .attr('width', '100%') + .attr('height', '100%') + .attr('viewBox', `0 0 ${width} ${height}`); + + const g = svg.append('g'); + + // Zoom + const zoom = d3.zoom() + .scaleExtent([0.1, 4]) + .on('zoom', (event: { transform: string }) => { + g.attr('transform', event.transform); + }); + + svg.call(zoom); + + // Simulation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.simulation = d3.forceSimulation(nodes as any) + .force('link', d3.forceLink(edges) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .id((d: any) => d.id) + .distance(state.graphSettings.linkDist)) + .force('charge', d3.forceManyBody().strength(-150)) + .force('center', d3.forceCenter(width / 2, height / 2)) + .force('collision', d3.forceCollide().radius(state.graphSettings.nodeSize + 5)); + + // Links + const link = g.append('g') + .selectAll('line') + .data(edges) + .join('line') + .attr('stroke', (d: GraphLink) => EDGE_COLORS[d.type] || '#999') + .attr('stroke-width', (d: GraphLink) => Math.sqrt(d.weight)) + .attr('stroke-opacity', 0.6); + + // Nodes + const node = g.append('g') + .selectAll('g') + .data(nodes) + .join('g') + .attr('cursor', 'pointer') + .call(this.createDrag(d3, this.simulation)); + + const nodeSize = state.graphSettings.nodeSize; + + if (state.graphSettings.showImg) { + node.append('image') + .attr('xlink:href', (d: GraphNode) => d.img || '') + .attr('width', nodeSize) + .attr('height', nodeSize) + .attr('x', -nodeSize / 2) + .attr('y', -nodeSize / 2) + .attr('clip-path', 'circle(50%)'); + + // Fallback for nodes without image + node.filter((d: GraphNode) => !d.img) + .append('circle') + .attr('r', nodeSize / 2) + .attr('fill', (d: GraphNode) => CATS[d.cat]?.color || '#7c8aff'); + } else { + node.append('circle') + .attr('r', nodeSize / 2) + .attr('fill', (d: GraphNode) => CATS[d.cat]?.color || '#7c8aff'); + } + + if (state.graphSettings.showLbl) { + node.append('text') + .text((d: GraphNode) => d.ref) + .attr('dy', nodeSize / 2 + 12) + .attr('text-anchor', 'middle') + .attr('font-size', 10) + .attr('fill', 'var(--text-primary)'); + } + + node.on('click', (_: MouseEvent, d: GraphNode) => { + if (state.selectionMode) { + const newSelected = new Set(state.selected); + if (newSelected.has(d.id)) { + newSelected.delete(d.id); + } else { + newSelected.add(d.id); + } + this.store.setState({ selected: newSelected }); + } else { + this.showDetail(d.id); + } + }); + + // Tick + this.simulation.on('tick', () => { + link + .attr('x1', (d: GraphLink) => (d.source as GraphNode).x!) + .attr('y1', (d: GraphLink) => (d.source as GraphNode).y!) + .attr('x2', (d: GraphLink) => (d.target as GraphNode).x!) + .attr('y2', (d: GraphLink) => (d.target as GraphNode).y!); + + node.attr('transform', (d: GraphNode) => `translate(${d.x},${d.y})`); + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private createDrag(d3: D3Module, simulation: D3Simulation): any { + return d3.drag() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .on('start', (event: any, d: any) => { + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .on('drag', (event: any, d: any) => { + d.fx = event.x; + d.fy = event.y; + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .on('end', (event: any, d: any) => { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + }); + } + + unmount(): void { + this.simulation?.stop(); + super.unmount(); + } +} diff --git a/deck-frontend/backups/20260113_211524/src/views/GridView/GridView.ts b/deck-frontend/backups/20260113_211524/src/views/GridView/GridView.ts new file mode 100644 index 0000000..48eef00 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/views/GridView/GridView.ts @@ -0,0 +1,75 @@ +import { View } from '../View.ts'; +import { filterTags, getName, getImg, delegateEvent } from '@/utils/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState } from '@/types/index.ts'; + +export class GridView extends View { + private showDetail: (mrf: string) => void; + + constructor( + container: HTMLElement, + store: Store, + showDetail: (mrf: string) => void + ) { + super(container, store); + this.showDetail = showDetail; + } + + render(): void { + const state = this.getState(); + const filtered = filterTags(state.tags, { + search: state.search, + group: state.group, + library: state.library, + libraryMembers: state.libraryMembers, + lang: state.lang + }); + + if (filtered.length === 0) { + this.container.innerHTML = '
Sin resultados
'; + return; + } + + this.container.innerHTML = filtered.map(tag => { + const img = getImg(tag); + const name = getName(tag, state.lang); + const isSelected = state.selected.has(tag.mrf); + + return ` +
+ ${state.selectionMode ? ` + + ` : ''} + ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 2) || 'T'}
` + } +
${name}
+
+ `; + }).join(''); + + this.bindEvents(); + } + + private bindEvents(): void { + const state = this.getState(); + + delegateEvent(this.container, '.card', 'click', (_, target) => { + const mrf = target.dataset.mrf; + if (!mrf) return; + + if (state.selectionMode) { + const newSelected = new Set(state.selected); + if (newSelected.has(mrf)) { + newSelected.delete(mrf); + } else { + newSelected.add(mrf); + } + this.setState({ selected: newSelected }); + } else { + this.showDetail(mrf); + } + }); + } +} diff --git a/deck-frontend/backups/20260113_211524/src/views/TreeView/TreeView.ts b/deck-frontend/backups/20260113_211524/src/views/TreeView/TreeView.ts new file mode 100644 index 0000000..9581c5d --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/views/TreeView/TreeView.ts @@ -0,0 +1,109 @@ +import { View } from '../View.ts'; +import { filterTags, getName, getImg, delegateEvent, createNameMap, resolveGroupName } from '@/utils/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, Tag } from '@/types/index.ts'; + +export class TreeView extends View { + private showDetail: (mrf: string) => void; + private expanded: Set = new Set(); + + constructor( + container: HTMLElement, + store: Store, + showDetail: (mrf: string) => void + ) { + super(container, store); + this.showDetail = showDetail; + } + + render(): void { + const state = this.getState(); + // Use hstTags for group name resolution (set_hst points to hst tags) + const nameMap = createNameMap(state.hstTags, state.lang); + const filtered = filterTags(state.tags, { + search: state.search, + group: state.group, + library: state.library, + libraryMembers: state.libraryMembers, + lang: state.lang + }); + + // Group by set_hst + const groups = new Map(); + filtered.forEach(tag => { + const group = tag.set_hst || 'sin-grupo'; + if (!groups.has(group)) groups.set(group, []); + groups.get(group)!.push(tag); + }); + + if (groups.size === 0) { + this.container.innerHTML = '
Sin resultados
'; + return; + } + + this.container.innerHTML = Array.from(groups.entries()).map(([groupMrf, tags]) => { + const isExpanded = this.expanded.has(groupMrf); + const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap); + return ` +
+
+ ${isExpanded ? '−' : '+'} + ${groupName} + ${tags.length} +
+
+ ${tags.map(tag => { + const img = getImg(tag); + const name = getName(tag, state.lang); + const isSelected = state.selected.has(tag.mrf); + return ` +
+ ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 1) || 'T'}
` + } + ${name} +
+ `; + }).join('')} +
+
+ `; + }).join(''); + + this.bindEvents(); + } + + private bindEvents(): void { + const state = this.getState(); + + delegateEvent(this.container, '.tree-header', 'click', (_, target) => { + const group = target.dataset.group; + if (!group) return; + + if (this.expanded.has(group)) { + this.expanded.delete(group); + } else { + this.expanded.add(group); + } + this.render(); + }); + + delegateEvent(this.container, '.tree-item', 'click', (_, target) => { + const mrf = target.dataset.mrf; + if (!mrf) return; + + if (state.selectionMode) { + const newSelected = new Set(state.selected); + if (newSelected.has(mrf)) { + newSelected.delete(mrf); + } else { + newSelected.add(mrf); + } + this.setState({ selected: newSelected }); + } else { + this.showDetail(mrf); + } + }); + } +} diff --git a/deck-frontend/backups/20260113_211524/src/views/View.ts b/deck-frontend/backups/20260113_211524/src/views/View.ts new file mode 100644 index 0000000..7697010 --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/views/View.ts @@ -0,0 +1,33 @@ +import type { Store } from '@/state/store.ts'; +import type { AppState } from '@/types/index.ts'; + +export abstract class View { + protected container: HTMLElement; + protected store: Store; + protected unsubscribe?: () => void; + + constructor(container: HTMLElement, store: Store) { + this.container = container; + this.store = store; + } + + abstract render(): void; + + mount(): void { + this.unsubscribe = this.store.subscribe(() => this.render()); + this.render(); + } + + unmount(): void { + this.unsubscribe?.(); + this.container.innerHTML = ''; + } + + protected getState(): AppState { + return this.store.getState(); + } + + protected setState(partial: Partial): void { + this.store.setState(partial); + } +} diff --git a/deck-frontend/backups/20260113_211524/src/views/index.ts b/deck-frontend/backups/20260113_211524/src/views/index.ts new file mode 100644 index 0000000..46261be --- /dev/null +++ b/deck-frontend/backups/20260113_211524/src/views/index.ts @@ -0,0 +1,5 @@ +export { View } from './View.ts'; +export { GridView } from './GridView/GridView.ts'; +export { TreeView } from './TreeView/TreeView.ts'; +export { GraphView } from './GraphView/GraphView.ts'; +export { DetailPanel } from './DetailPanel/DetailPanel.ts'; diff --git a/deck-frontend/backups/20260113_212146/index.html b/deck-frontend/backups/20260113_212146/index.html new file mode 100644 index 0000000..8881f20 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/index.html @@ -0,0 +1,129 @@ + + + + DECK + + + + +
+ +
+
+ + + +
+
+ +
+ + + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+ + +
+
+ + + +
+
+ + + +
+
+
+ + +
+ + +
+ +
+
ALL
+
+ + +
+
+
Cargando...
+
+
+ + +
+
+ + + +
+ + + + diff --git a/deck-frontend/backups/20260113_212146/src/api/client.ts b/deck-frontend/backups/20260113_212146/src/api/client.ts new file mode 100644 index 0000000..03f0f69 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/api/client.ts @@ -0,0 +1,43 @@ +import { API_BASE } from '@/config/index.ts'; + +interface FetchOptions { + method?: 'GET' | 'POST'; + body?: Record; + schema?: string; // PostgREST Accept-Profile header +} + +export async function apiClient( + endpoint: string, + options: FetchOptions = {} +): Promise { + const { method = 'GET', body, schema } = options; + + const headers: Record = {}; + if (body) headers['Content-Type'] = 'application/json'; + if (schema) headers['Accept-Profile'] = schema; + + const config: RequestInit = { + method, + headers: Object.keys(headers).length > 0 ? headers : undefined, + body: body ? JSON.stringify(body) : undefined, + }; + + const response = await fetch(`${API_BASE}${endpoint}`, config); + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + return response.json(); +} + +export async function apiClientSafe( + endpoint: string, + options: FetchOptions = {}, + fallback: T +): Promise { + try { + return await apiClient(endpoint, options); + } catch { + console.error(`API call failed: ${endpoint}`); + return fallback; + } +} diff --git a/deck-frontend/backups/20260113_212146/src/api/graph.ts b/deck-frontend/backups/20260113_212146/src/api/graph.ts new file mode 100644 index 0000000..22b0b66 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/api/graph.ts @@ -0,0 +1,8 @@ +import { apiClientSafe } from './client.ts'; +import type { GraphEdge, TreeEdge } from '@/types/index.ts'; + +export const fetchGraphEdges = (): Promise => + apiClientSafe('/graph_hst', {}, []); + +export const fetchTreeEdges = (): Promise => + apiClientSafe('/tree_hst', {}, []); diff --git a/deck-frontend/backups/20260113_212146/src/api/groups.ts b/deck-frontend/backups/20260113_212146/src/api/groups.ts new file mode 100644 index 0000000..af08297 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/api/groups.ts @@ -0,0 +1,5 @@ +import { apiClientSafe } from './client.ts'; +import type { Group } from '@/types/index.ts'; + +export const fetchGroups = (): Promise => + apiClientSafe('/api_groups', {}, []); diff --git a/deck-frontend/backups/20260113_212146/src/api/index.ts b/deck-frontend/backups/20260113_212146/src/api/index.ts new file mode 100644 index 0000000..8d6cd78 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/api/index.ts @@ -0,0 +1,5 @@ +export { apiClient, apiClientSafe } from './client.ts'; +export { fetchTags, fetchHstTags, fetchChildren, fetchRelated } from './tags.ts'; +export { fetchGroups } from './groups.ts'; +export { fetchLibraries, fetchLibraryMembers } from './libraries.ts'; +export { fetchGraphEdges, fetchTreeEdges } from './graph.ts'; diff --git a/deck-frontend/backups/20260113_212146/src/api/libraries.ts b/deck-frontend/backups/20260113_212146/src/api/libraries.ts new file mode 100644 index 0000000..44f5f3a --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/api/libraries.ts @@ -0,0 +1,26 @@ +import { apiClientSafe } from './client.ts'; +import type { Library, BaseType } from '@/types/index.ts'; + +// Base types that have library tables (public schema taxonomy tables) +const LIBRARY_BASES = new Set(['hst', 'flg', 'itm', 'loc', 'ply']); + +export const fetchLibraries = (base: BaseType): Promise => { + // Only public schema taxonomy tables have libraries + if (!LIBRARY_BASES.has(base)) { + return Promise.resolve([]); + } + // Use base-specific view: api_library_list_hst, api_library_list_flg, etc. + return apiClientSafe(`/api_library_list_${base}`, {}, []); +}; + +export const fetchLibraryMembers = async (mrf: string, base: BaseType): Promise => { + if (!LIBRARY_BASES.has(base)) { + return []; + } + const data = await apiClientSafe>( + `/library_${base}?mrf_library=eq.${mrf}`, + {}, + [] + ); + return data.map(d => d.mrf_tag); +}; diff --git a/deck-frontend/backups/20260113_212146/src/api/tags.ts b/deck-frontend/backups/20260113_212146/src/api/tags.ts new file mode 100644 index 0000000..cba9f0b --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/api/tags.ts @@ -0,0 +1,65 @@ +import { apiClientSafe } from './client.ts'; +import type { Tag, ChildTag, RelatedTag, BaseType } from '@/types/index.ts'; + +// Schema mapping by base type +// - public (default): hst, flg, itm, loc, ply +// - secretaria_clara: atc, mst, bck +// - production_alfred: mth +// - mail_manager: mail (table: clara_registros) +// - context_manager: chat (table: messages) + +interface SchemaTableConfig { + schema: string | null; + table: string; +} + +const getSchemaAndTable = (base: BaseType): SchemaTableConfig => { + switch (base) { + // secretaria_clara schema + case 'atc': + case 'mst': + case 'bck': + return { schema: 'secretaria_clara', table: base }; + + // production_alfred schema + case 'mth': + return { schema: 'production_alfred', table: base }; + + // mail_manager schema + case 'mail': + return { schema: 'mail_manager', table: 'clara_registros' }; + + // context_manager schema + case 'chat': + return { schema: 'context_manager', table: 'messages' }; + + // public schema (default) - hst, flg, itm, loc, ply + default: + return { schema: null, table: base }; + } +}; + +export const fetchTags = (base: BaseType): Promise => { + const { schema, table } = getSchemaAndTable(base); + return apiClientSafe( + `/${table}?order=ref.asc`, + schema ? { schema } : {}, + [] + ); +}; + +// Fetch HST tags for group name resolution (set_hst points to hst tags) +export const fetchHstTags = (): Promise => + apiClientSafe('/hst?select=mrf,ref,alias,name_es,name_en,name_ch', {}, []); + +export const fetchChildren = (mrf: string): Promise => + apiClientSafe('/rpc/api_children', { + method: 'POST', + body: { parent_mrf: mrf } + }, []); + +export const fetchRelated = (mrf: string): Promise => + apiClientSafe('/rpc/api_related', { + method: 'POST', + body: { tag_mrf: mrf } + }, []); diff --git a/deck-frontend/backups/20260113_212146/src/components/Card/Card.ts b/deck-frontend/backups/20260113_212146/src/components/Card/Card.ts new file mode 100644 index 0000000..318db1d --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/components/Card/Card.ts @@ -0,0 +1,47 @@ +import { Component } from '../Component.ts'; +import type { Tag, LangType } from '@/types/index.ts'; +import { getName, getImg } from '@/utils/index.ts'; + +export interface CardProps { + tag: Tag; + lang: LangType; + selected: boolean; + selectionMode: boolean; + onClick: (mrf: string) => void; + onSelect: (mrf: string) => void; +} + +export class Card extends Component { + protected template(): string { + const { tag, lang, selected, selectionMode } = this.props; + const img = getImg(tag); + const name = getName(tag, lang); + + return ` +
+ ${selectionMode ? ` + + ` : ''} + ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 2) || 'T'}
` + } +
${name}
+
+ `; + } + + protected bindEvents(): void { + const { onClick, onSelect, selectionMode } = this.props; + const mrf = this.props.tag.mrf; + + this.element.addEventListener('click', (e) => { + if (selectionMode) { + e.preventDefault(); + onSelect(mrf); + } else { + onClick(mrf); + } + }); + } +} diff --git a/deck-frontend/backups/20260113_212146/src/components/Component.ts b/deck-frontend/backups/20260113_212146/src/components/Component.ts new file mode 100644 index 0000000..f68cf52 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/components/Component.ts @@ -0,0 +1,42 @@ +export abstract class Component

{ + protected element: HTMLElement; + protected props: P; + + constructor(props: P) { + this.props = props; + this.element = this.createElement(); + this.bindEvents(); + } + + protected abstract template(): string; + + protected createElement(): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.innerHTML = this.template().trim(); + return wrapper.firstElementChild as HTMLElement; + } + + protected bindEvents(): void { + // Override in subclasses + } + + public mount(container: HTMLElement): void { + container.appendChild(this.element); + } + + public unmount(): void { + this.element.remove(); + } + + public update(props: Partial

): void { + this.props = { ...this.props, ...props }; + const newElement = this.createElement(); + this.element.replaceWith(newElement); + this.element = newElement; + this.bindEvents(); + } + + public getElement(): HTMLElement { + return this.element; + } +} diff --git a/deck-frontend/backups/20260113_212146/src/components/Modal/Modal.ts b/deck-frontend/backups/20260113_212146/src/components/Modal/Modal.ts new file mode 100644 index 0000000..d3b0bb8 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/components/Modal/Modal.ts @@ -0,0 +1,46 @@ +import { Component } from '../Component.ts'; + +export interface ModalProps { + title: string; + content: string; + isOpen: boolean; + onClose: () => void; +} + +export class Modal extends Component { + protected template(): string { + const { title, content, isOpen } = this.props; + return ` +

+ `; + } + + protected bindEvents(): void { + const closeBtn = this.element.querySelector('.modal-close'); + closeBtn?.addEventListener('click', this.props.onClose); + + this.element.addEventListener('click', (e) => { + if (e.target === this.element) { + this.props.onClose(); + } + }); + } + + public open(): void { + this.element.classList.add('open'); + } + + public close(): void { + this.element.classList.remove('open'); + } +} diff --git a/deck-frontend/backups/20260113_212146/src/components/TagChip/TagChip.ts b/deck-frontend/backups/20260113_212146/src/components/TagChip/TagChip.ts new file mode 100644 index 0000000..271bc5e --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/components/TagChip/TagChip.ts @@ -0,0 +1,25 @@ +import { Component } from '../Component.ts'; + +export interface TagChipProps { + mrf: string; + label: string; + title?: string; + onClick: (mrf: string) => void; +} + +export class TagChip extends Component { + protected template(): string { + const { mrf, label, title } = this.props; + return ` + + ${label} + + `; + } + + protected bindEvents(): void { + this.element.addEventListener('click', () => { + this.props.onClick(this.props.mrf); + }); + } +} diff --git a/deck-frontend/backups/20260113_212146/src/components/index.ts b/deck-frontend/backups/20260113_212146/src/components/index.ts new file mode 100644 index 0000000..df484ec --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/components/index.ts @@ -0,0 +1,4 @@ +export { Component } from './Component.ts'; +export { Card, type CardProps } from './Card/Card.ts'; +export { TagChip, type TagChipProps } from './TagChip/TagChip.ts'; +export { Modal, type ModalProps } from './Modal/Modal.ts'; diff --git a/deck-frontend/backups/20260113_212146/src/config/api.ts b/deck-frontend/backups/20260113_212146/src/config/api.ts new file mode 100644 index 0000000..7030377 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/config/api.ts @@ -0,0 +1 @@ +export const API_BASE = '/api'; diff --git a/deck-frontend/backups/20260113_212146/src/config/categories.ts b/deck-frontend/backups/20260113_212146/src/config/categories.ts new file mode 100644 index 0000000..a19de89 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/config/categories.ts @@ -0,0 +1,15 @@ +import type { CategoryKey } from '@/types/index.ts'; + +export interface CategoryConfig { + name: string; + color: string; +} + +export const CATS: Record = { + hst: { name: 'Hashtags', color: '#7c8aff' }, + spe: { name: 'Specs', color: '#FF9800' }, + vue: { name: 'Values', color: '#00BCD4' }, + vsn: { name: 'Visions', color: '#E91E63' }, + msn: { name: 'Missions', color: '#9C27B0' }, + flg: { name: 'Flags', color: '#4CAF50' } +}; diff --git a/deck-frontend/backups/20260113_212146/src/config/edges.ts b/deck-frontend/backups/20260113_212146/src/config/edges.ts new file mode 100644 index 0000000..ab64dd8 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/config/edges.ts @@ -0,0 +1,14 @@ +import type { EdgeType } from '@/types/index.ts'; + +export const EDGE_COLORS: Record = { + relation: '#8BC34A', + specialization: '#9C27B0', + mirror: '#607D8B', + dependency: '#2196F3', + sequence: '#4CAF50', + composition: '#FF9800', + hierarchy: '#E91E63', + library: '#00BCD4', + contextual: '#FFC107', + association: '#795548' +}; diff --git a/deck-frontend/backups/20260113_212146/src/config/index.ts b/deck-frontend/backups/20260113_212146/src/config/index.ts new file mode 100644 index 0000000..23dfe12 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/config/index.ts @@ -0,0 +1,3 @@ +export { CATS, type CategoryConfig } from './categories.ts'; +export { EDGE_COLORS } from './edges.ts'; +export { API_BASE } from './api.ts'; diff --git a/deck-frontend/backups/20260113_212146/src/main.ts b/deck-frontend/backups/20260113_212146/src/main.ts new file mode 100644 index 0000000..d08978e --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/main.ts @@ -0,0 +1,484 @@ +import { store } from '@/state/index.ts'; +import { Router } from '@/router/index.ts'; +import { fetchTags, fetchHstTags, fetchGroups, fetchLibraries, fetchLibraryMembers } from '@/api/index.ts'; +import { GridView, TreeView, GraphView, DetailPanel } from '@/views/index.ts'; +import { $, $$, delegateEvent, toast, createNameMap, resolveGroupName } from '@/utils/index.ts'; +import { CATS, EDGE_COLORS } from '@/config/index.ts'; +import type { BaseType, ViewType, CategoryKey, EdgeType } from '@/types/index.ts'; +import './styles/main.css'; + +class App { + private router: Router; + private currentView: GridView | TreeView | GraphView | null = null; + private detailPanel: DetailPanel | null = null; + + constructor() { + this.router = new Router(store, () => this.init()); + } + + async start(): Promise { + this.router.parseHash(); + await this.init(); + this.bindEvents(); + } + + private async init(): Promise { + const contentArea = $('#content-area'); + const detailPanelEl = $('#detail-panel'); + if (!contentArea || !detailPanelEl) return; + + // Update UI + this.updateBaseButtons(); + this.updateViewTabs(); + + // Show loading + contentArea.innerHTML = '
Cargando...
'; + + // Fetch data + const state = store.getState(); + const [tags, hstTags, groups, libraries] = await Promise.all([ + fetchTags(state.base), + fetchHstTags(), // Always load HST for group name resolution + fetchGroups(), + fetchLibraries(state.base) // Load libraries for current base + ]); + store.setState({ tags, hstTags, groups, libraries }); + + // Render groups + this.renderGroups(); + this.renderLibraries(); + + // Setup detail panel + if (!this.detailPanel) { + this.detailPanel = new DetailPanel(detailPanelEl, store); + } + + // Render view + this.renderView(); + } + + private renderView(): void { + const contentArea = $('#content-area'); + if (!contentArea) return; + + const state = store.getState(); + const showDetail = (mrf: string) => this.detailPanel?.showDetail(mrf); + + // Unmount current view + this.currentView?.unmount(); + + // Clear and set class + contentArea.innerHTML = ''; + contentArea.className = `content-area ${state.view}-view`; + + // Mount new view + switch (state.view) { + case 'grid': + this.currentView = new GridView(contentArea, store, showDetail); + this.currentView.mount(); + break; + case 'tree': + this.currentView = new TreeView(contentArea, store, showDetail); + this.currentView.mount(); + break; + case 'graph': + this.currentView = new GraphView(contentArea, store, showDetail); + (this.currentView as GraphView).mount(); + break; + } + } + + private renderGroups(): void { + const container = $('#groups-bar'); + if (!container) return; + + const state = store.getState(); + // Use hstTags for group name resolution (set_hst points to hst tags) + const nameMap = createNameMap(state.hstTags, state.lang); + + // Count tags per group + const counts = new Map(); + state.tags.forEach(tag => { + const group = tag.set_hst || 'sin-grupo'; + counts.set(group, (counts.get(group) || 0) + 1); + }); + + // Sort by count and take top 20 + const sorted = Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20); + + container.innerHTML = ` + + ${sorted.map(([groupMrf, count]) => { + const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap); + return ` + + `; + }).join('')} + `; + + delegateEvent(container, '.group-btn', 'click', (_, target) => { + const group = target.dataset.group || 'all'; + store.setState({ group }); + this.renderGroups(); + this.renderView(); + }); + } + + private renderLibraries(): void { + const container = $('#left-panel'); + if (!container) return; + + const state = store.getState(); + + // Show graph options when in graph view + if (state.view === 'graph') { + container.classList.add('graph-mode'); + this.renderGraphOptions(container); + return; + } + + container.classList.remove('graph-mode'); + container.innerHTML = ` +
+ ALL +
+ ${state.libraries.map(lib => { + const icon = lib.img_thumb_url || lib.icon_url || ''; + const name = lib.name || lib.name_es || lib.alias || lib.ref || lib.mrf.slice(0, 6); + return ` +
+ ${icon ? `` : ''} + ${name.slice(0, 8)} +
+ `; + }).join('')} + `; + + delegateEvent(container, '.lib-icon', 'click', async (_, target) => { + const library = target.dataset.lib || 'all'; + const currentBase = store.getState().base; + + if (library === 'all') { + store.setState({ library: 'all', libraryMembers: new Set() }); + } else { + const members = await fetchLibraryMembers(library, currentBase); + store.setState({ library, libraryMembers: new Set(members) }); + } + + this.renderLibraries(); + this.renderView(); + }); + } + + private renderGraphOptions(container: HTMLElement): void { + const state = store.getState(); + const { graphFilters, graphSettings, tags, graphEdges } = state; + + // Count nodes and edges + const nodeCount = tags.length; + const edgeCount = graphEdges.length; + + container.innerHTML = ` +
+ +
+
+ Nodos + ${nodeCount} +
+
+ Edges + ${edgeCount} +
+
+ + +
+
Categorias
+ ${Object.entries(CATS).map(([key, config]) => ` + + `).join('')} +
+ + +
+
Relaciones
+ ${Object.entries(EDGE_COLORS).map(([key, color]) => ` + + `).join('')} +
+ + +
+
Visualizacion
+ + +
+
+ Nodo + ${graphSettings.nodeSize}px +
+ +
+
+
+ Distancia + ${graphSettings.linkDist}px +
+ +
+
+
+ `; + + // Bind events + this.bindGraphOptionEvents(container); + } + + private bindGraphOptionEvents(container: HTMLElement): void { + // Category checkboxes + container.querySelectorAll('[data-cat]').forEach(cb => { + cb.addEventListener('change', () => { + const cat = cb.dataset.cat as CategoryKey; + const state = store.getState(); + const newCats = new Set(state.graphFilters.cats); + if (cb.checked) { + newCats.add(cat); + } else { + newCats.delete(cat); + } + store.setState({ + graphFilters: { ...state.graphFilters, cats: newCats } + }); + this.renderView(); + }); + }); + + // Edge checkboxes + container.querySelectorAll('[data-edge]').forEach(cb => { + cb.addEventListener('change', () => { + const edge = cb.dataset.edge as EdgeType; + const state = store.getState(); + const newEdges = new Set(state.graphFilters.edges); + if (cb.checked) { + newEdges.add(edge); + } else { + newEdges.delete(edge); + } + store.setState({ + graphFilters: { ...state.graphFilters, edges: newEdges } + }); + this.renderView(); + }); + }); + + // Show images checkbox + const showImgCb = container.querySelector('#graph-show-img'); + showImgCb?.addEventListener('change', () => { + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, showImg: showImgCb.checked } + }); + this.renderView(); + }); + + // Show labels checkbox + const showLblCb = container.querySelector('#graph-show-lbl'); + showLblCb?.addEventListener('change', () => { + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, showLbl: showLblCb.checked } + }); + this.renderView(); + }); + + // Node size slider + const nodeSizeSlider = container.querySelector('#graph-node-size'); + const nodeSizeVal = container.querySelector('#node-size-val'); + nodeSizeSlider?.addEventListener('input', () => { + const size = parseInt(nodeSizeSlider.value, 10); + if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`; + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, nodeSize: size } + }); + this.renderView(); + }); + + // Link distance slider + const linkDistSlider = container.querySelector('#graph-link-dist'); + const linkDistVal = container.querySelector('#link-dist-val'); + linkDistSlider?.addEventListener('input', () => { + const dist = parseInt(linkDistSlider.value, 10); + if (linkDistVal) linkDistVal.textContent = `${dist}px`; + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, linkDist: dist } + }); + this.renderView(); + }); + } + + private updateBaseButtons(): void { + const state = store.getState(); + $$('.base-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.base === state.base); + }); + } + + private updateViewTabs(): void { + const state = store.getState(); + $$('.view-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.view === state.view); + }); + } + + private bindEvents(): void { + // Base buttons + delegateEvent(document.body, '.base-btn', 'click', async (_, target) => { + const base = target.dataset.base as BaseType; + if (!base) return; + + store.setState({ + base, + group: 'all', + library: 'all', + libraryMembers: new Set(), + search: '', + graphEdges: [], + treeEdges: [], + selected: new Set(), + selectionMode: false + }); + + this.router.updateHash(); + await this.init(); + }); + + // View tabs + delegateEvent(document.body, '.view-tab', 'click', (_, target) => { + const view = target.dataset.view as ViewType; + if (!view) return; + + store.setState({ view }); + this.router.updateHash(); + this.detailPanel?.close(); + this.updateViewTabs(); + this.renderLibraries(); // Update left panel (graph options vs libraries) + this.renderView(); + }); + + // Search + const searchInput = $('#search') as HTMLInputElement; + if (searchInput) { + let timeout: number; + searchInput.addEventListener('input', () => { + clearTimeout(timeout); + timeout = window.setTimeout(() => { + store.setState({ search: searchInput.value }); + this.renderView(); + }, 200); + }); + } + + // Language select + const langSelect = $('#lang-select') as HTMLSelectElement; + if (langSelect) { + langSelect.addEventListener('change', () => { + store.setState({ lang: langSelect.value as 'es' | 'en' | 'ch' }); + this.renderView(); + }); + } + + // Selection mode + const selBtn = $('#btn-sel'); + if (selBtn) { + selBtn.addEventListener('click', () => { + const state = store.getState(); + store.setState({ + selectionMode: !state.selectionMode, + selected: state.selectionMode ? new Set() : state.selected + }); + selBtn.classList.toggle('active', !state.selectionMode); + this.updateSelectionCount(); + this.renderView(); + }); + } + + // Get selected + const getBtn = $('#btn-get'); + if (getBtn) { + getBtn.addEventListener('click', () => { + const state = store.getState(); + if (state.selected.size === 0) { + toast('No hay seleccionados'); + return; + } + navigator.clipboard.writeText([...state.selected].join('\n')) + .then(() => toast(`Copiados ${state.selected.size} mrfs`)); + }); + } + + // API modal + const apiBtn = $('#btn-api'); + const apiModal = $('#api-modal'); + if (apiBtn && apiModal) { + apiBtn.addEventListener('click', () => apiModal.classList.add('open')); + apiModal.addEventListener('click', (e) => { + if (e.target === apiModal) apiModal.classList.remove('open'); + }); + const closeBtn = apiModal.querySelector('.modal-close'); + closeBtn?.addEventListener('click', () => apiModal.classList.remove('open')); + } + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.detailPanel?.close(); + $('#api-modal')?.classList.remove('open'); + if (store.getState().selectionMode) { + store.setState({ selectionMode: false, selected: new Set() }); + $('#btn-sel')?.classList.remove('active'); + this.renderView(); + } + } + if (e.key === '/' && (e.target as HTMLElement).tagName !== 'INPUT') { + e.preventDefault(); + ($('#search') as HTMLInputElement)?.focus(); + } + }); + } + + private updateSelectionCount(): void { + const counter = $('#sel-count'); + if (counter) { + const count = store.getState().selected.size; + counter.textContent = count > 0 ? `(${count})` : ''; + } + } +} + +// Bootstrap +document.addEventListener('DOMContentLoaded', () => { + new App().start(); +}); diff --git a/deck-frontend/backups/20260113_212146/src/modules/configs/index.ts b/deck-frontend/backups/20260113_212146/src/modules/configs/index.ts new file mode 100644 index 0000000..0396879 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/modules/configs/index.ts @@ -0,0 +1,368 @@ +/** + * Module Configurations - Registro central de todos los módulos + */ + +import type { BaseConfig, ModuleCategory } from '../registry.ts'; +import type { BaseType, ViewType } from '@/types/index.ts'; + +// Configuración de todos los módulos +export const MODULE_CONFIGS: Record = { + // ═══════════════════════════════════════════════════════════════ + // TAXONOMÍA (public schema) + // ═══════════════════════════════════════════════════════════════ + hst: { + id: 'hst', + name: 'Hashtags Semánticos', + shortName: 'HST', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: true }, + defaultView: 'grid', + api: { + schema: null, + table: 'hst', + hasLibraries: true, + hasGroups: true, + hasGraph: true, + hasTree: true + }, + enabled: true + }, + + flg: { + id: 'flg', + name: 'Flags', + shortName: 'FLG', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'flg', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + itm: { + id: 'itm', + name: 'Items', + shortName: 'ITM', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'itm', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + loc: { + id: 'loc', + name: 'Locations', + shortName: 'LOC', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'loc', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + ply: { + id: 'ply', + name: 'Players', + shortName: 'PLY', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'ply', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + // ═══════════════════════════════════════════════════════════════ + // MAESTROS (secretaria_clara schema) + // ═══════════════════════════════════════════════════════════════ + mst: { + id: 'mst', + name: 'Masters', + shortName: 'MST', + category: 'masters', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'secretaria_clara', + table: 'mst', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + bck: { + id: 'bck', + name: 'Backups', + shortName: 'BCK', + category: 'masters', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'secretaria_clara', + table: 'bck', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + // ═══════════════════════════════════════════════════════════════ + // REGISTRO (secretaria_clara / production_alfred) + // ═══════════════════════════════════════════════════════════════ + atc: { + id: 'atc', + name: 'Attachments', + shortName: 'ATC', + category: 'registry', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'secretaria_clara', + table: 'atc', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + mth: { + id: 'mth', + name: 'Methods', + shortName: 'MTH', + category: 'registry', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'production_alfred', + table: 'mth', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + // ═══════════════════════════════════════════════════════════════ + // COMUNICACIÓN (mail_manager / context_manager) + // Interfaz de chat con IA + // ═══════════════════════════════════════════════════════════════ + mail: { + id: 'mail', + name: 'Mail Assistant', + shortName: 'MAIL', + category: 'communication', + renderType: 'chat', + views: { custom: 'ChatView' }, + defaultView: 'custom', + api: { + schema: 'mail_manager', + table: 'clara_registros', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/MailModule.ts'), + enabled: false // Próximamente + }, + + chat: { + id: 'chat', + name: 'Context Manager', + shortName: 'CHAT', + category: 'communication', + renderType: 'chat', + views: { custom: 'ChatView' }, + defaultView: 'custom', + api: { + schema: 'context_manager', + table: 'messages', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/ContextModule.ts'), + enabled: false // Próximamente + }, + + // ═══════════════════════════════════════════════════════════════ + // SERVICIOS (interfaces custom) + // ═══════════════════════════════════════════════════════════════ + key: { + id: 'key', + name: 'Keys', + shortName: 'KEY', + category: 'services', + renderType: 'custom', + views: { custom: 'KeyView' }, + defaultView: 'custom', + api: { + schema: null, + table: 'key', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/KeyModule.ts'), + enabled: false // Próximamente + }, + + mindlink: { + id: 'mindlink', + name: 'MindLink', + shortName: 'MIND', + category: 'services', + renderType: 'custom', + views: { custom: 'MindlinkView' }, + defaultView: 'custom', + api: { + schema: null, + table: 'mindlink', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/MindlinkModule.ts'), + enabled: false // Próximamente + } +}; + +// ═══════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════ + +/** + * Obtener configuración de un módulo + */ +export const getModuleConfig = (base: BaseType): BaseConfig => { + const config = MODULE_CONFIGS[base]; + if (!config) { + throw new Error(`Module config not found for base: ${base}`); + } + return config; +}; + +/** + * Agrupar módulos por categoría para UI + */ +export const getModulesByCategory = (): Record => { + const result: Record = { + taxonomy: [], + masters: [], + registry: [], + communication: [], + services: [] + }; + + Object.values(MODULE_CONFIGS).forEach(config => { + result[config.category].push(config); + }); + + return result; +}; + +/** + * Obtener solo módulos habilitados + */ +export const getEnabledModules = (): BaseConfig[] => { + return Object.values(MODULE_CONFIGS).filter(c => c.enabled !== false); +}; + +/** + * Verificar si un módulo está habilitado + */ +export const isModuleEnabled = (base: BaseType): boolean => { + return MODULE_CONFIGS[base]?.enabled !== false; +}; + +/** + * Obtener schema y tabla para API (compatibilidad con código existente) + */ +export const getSchemaAndTable = (base: BaseType): { schema: string | null; table: string } => { + const config = MODULE_CONFIGS[base]; + if (!config) { + return { schema: null, table: base }; + } + return { + schema: config.api.schema, + table: config.api.table + }; +}; + +/** + * Verificar si una base soporta bibliotecas + */ +export const supportsLibraries = (base: BaseType): boolean => { + return MODULE_CONFIGS[base]?.api.hasLibraries ?? false; +}; + +/** + * Verificar si una base soporta grupos + */ +export const supportsGroups = (base: BaseType): boolean => { + return MODULE_CONFIGS[base]?.api.hasGroups ?? false; +}; + +/** + * Verificar si una vista está soportada por una base + */ +export const supportsView = (base: BaseType, view: ViewType): boolean => { + const config = MODULE_CONFIGS[base]; + if (!config) return false; + return !!config.views[view]; +}; + +/** + * Obtener vista por defecto de una base + */ +export const getDefaultView = (base: BaseType): ViewType | 'custom' => { + return MODULE_CONFIGS[base]?.defaultView ?? 'grid'; +}; diff --git a/deck-frontend/backups/20260113_212146/src/modules/custom/ContextModule.ts b/deck-frontend/backups/20260113_212146/src/modules/custom/ContextModule.ts new file mode 100644 index 0000000..da0a0cf --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/modules/custom/ContextModule.ts @@ -0,0 +1,31 @@ +/** + * ContextModule - Módulo de chat con IA genérico + * + * TODO: Implementar interfaz de chat estilo ChatGPT/Claude + * para interactuar con diferentes modelos de IA controlando el contexto. + */ + +import { BaseModule } from '../registry.ts'; + +export class ContextModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
💬
+
Context Manager
+
Chat con IA - Próximamente
+
+ `; + } +} + +export default ContextModule; diff --git a/deck-frontend/backups/20260113_212146/src/modules/custom/KeyModule.ts b/deck-frontend/backups/20260113_212146/src/modules/custom/KeyModule.ts new file mode 100644 index 0000000..b5e20d2 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/modules/custom/KeyModule.ts @@ -0,0 +1,30 @@ +/** + * KeyModule - Módulo de gestión de claves + * + * TODO: Implementar interfaz para gestionar claves y credenciales. + */ + +import { BaseModule } from '../registry.ts'; + +export class KeyModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
🔑
+
Keys
+
Gestión de claves - Próximamente
+
+ `; + } +} + +export default KeyModule; diff --git a/deck-frontend/backups/20260113_212146/src/modules/custom/MailModule.ts b/deck-frontend/backups/20260113_212146/src/modules/custom/MailModule.ts new file mode 100644 index 0000000..1f5d816 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/modules/custom/MailModule.ts @@ -0,0 +1,31 @@ +/** + * MailModule - Módulo de chat con IA para mail + * + * TODO: Implementar interfaz de chat estilo ChatGPT/Claude + * donde el contexto es el correo electrónico. + */ + +import { BaseModule } from '../registry.ts'; + +export class MailModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
📧
+
Mail Assistant
+
Interfaz de chat con IA - Próximamente
+
+ `; + } +} + +export default MailModule; diff --git a/deck-frontend/backups/20260113_212146/src/modules/custom/MindlinkModule.ts b/deck-frontend/backups/20260113_212146/src/modules/custom/MindlinkModule.ts new file mode 100644 index 0000000..64073d5 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/modules/custom/MindlinkModule.ts @@ -0,0 +1,31 @@ +/** + * MindlinkModule - Módulo de gestión de hipervínculos + * + * TODO: Implementar interfaz de árboles visuales con imágenes + * y vínculos a archivos/recursos. + */ + +import { BaseModule } from '../registry.ts'; + +export class MindlinkModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
🔗
+
MindLink
+
Gestión de hipervínculos - Próximamente
+
+ `; + } +} + +export default MindlinkModule; diff --git a/deck-frontend/backups/20260113_212146/src/modules/custom/index.ts b/deck-frontend/backups/20260113_212146/src/modules/custom/index.ts new file mode 100644 index 0000000..3595038 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/modules/custom/index.ts @@ -0,0 +1,4 @@ +export { MailModule } from './MailModule.ts'; +export { ContextModule } from './ContextModule.ts'; +export { KeyModule } from './KeyModule.ts'; +export { MindlinkModule } from './MindlinkModule.ts'; diff --git a/deck-frontend/backups/20260113_212146/src/modules/index.ts b/deck-frontend/backups/20260113_212146/src/modules/index.ts new file mode 100644 index 0000000..71eb421 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/modules/index.ts @@ -0,0 +1,35 @@ +/** + * Modules - Sistema modular para DECK Frontend + */ + +// Registry (tipos e interfaces) +export { + type ModuleRenderType, + type ModuleCategory, + type ModuleViews, + type ModuleApiConfig, + type BaseConfig, + type ModuleState, + type ModuleContext, + BaseModule +} from './registry.ts'; + +// Configs (registro de módulos) +export { + MODULE_CONFIGS, + getModuleConfig, + getModulesByCategory, + getEnabledModules, + isModuleEnabled, + getSchemaAndTable, + supportsLibraries, + supportsGroups, + supportsView, + getDefaultView +} from './configs/index.ts'; + +// Loader +export { ModuleLoader, type LoaderTargets } from './loader.ts'; + +// Standard module +export { StandardModule } from './standard/index.ts'; diff --git a/deck-frontend/backups/20260113_212146/src/modules/loader.ts b/deck-frontend/backups/20260113_212146/src/modules/loader.ts new file mode 100644 index 0000000..3ced06f --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/modules/loader.ts @@ -0,0 +1,174 @@ +/** + * ModuleLoader - Carga dinámica de módulos + * + * Responsabilidades: + * - Cargar el módulo correcto según la base + * - Manejar cache de módulos + * - Gestionar lifecycle (mount/unmount) + */ + +import { BaseModule, type ModuleContext } from './registry.ts'; +import { getModuleConfig, isModuleEnabled } from './configs/index.ts'; +import { StandardModule } from './standard/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, BaseType } from '@/types/index.ts'; + +export interface LoaderTargets { + container: HTMLElement; + leftPanel: HTMLElement; + groupsBar: HTMLElement; + showDetail: (mrf: string) => void; +} + +export class ModuleLoader { + private store: Store; + private currentModule: BaseModule | null = null; + private currentBase: BaseType | null = null; + + constructor(store: Store) { + this.store = store; + } + + /** + * Cargar módulo para una base + */ + async load(base: BaseType, targets: LoaderTargets): Promise { + const config = getModuleConfig(base); + + // Verificar si el módulo está habilitado + if (!isModuleEnabled(base)) { + this.showDisabledMessage(targets.container, config.name); + targets.leftPanel.innerHTML = ''; + targets.groupsBar.innerHTML = ''; + return; + } + + // Si ya está cargado el mismo módulo, solo re-renderizar + if (this.currentBase === base && this.currentModule) { + this.currentModule.render(); + return; + } + + // Unmount módulo actual + this.currentModule?.unmount(); + this.currentModule = null; + this.currentBase = null; + + // Show loading + targets.container.innerHTML = '
Cargando...
'; + + // Crear contexto + const ctx: ModuleContext = { + container: targets.container, + leftPanel: targets.leftPanel, + groupsBar: targets.groupsBar, + store: this.store, + config, + showDetail: targets.showDetail + }; + + // Crear módulo según tipo + let module: BaseModule; + + switch (config.renderType) { + case 'standard': + module = new StandardModule(ctx); + break; + + case 'chat': + case 'custom': + // Carga dinámica de módulos custom + if (config.customModule) { + try { + const { default: CustomModule } = await config.customModule(); + module = new CustomModule(ctx); + } catch (error) { + console.error(`Failed to load custom module for ${base}:`, error); + this.showErrorMessage(targets.container, `Error cargando módulo ${config.name}`); + return; + } + } else { + this.showDisabledMessage(targets.container, config.name); + return; + } + break; + + default: + console.error(`Unknown render type: ${config.renderType}`); + this.showErrorMessage(targets.container, 'Tipo de módulo desconocido'); + return; + } + + // Mount módulo + try { + await module.mount(); + this.currentModule = module; + this.currentBase = base; + } catch (error) { + console.error(`Failed to mount module for ${base}:`, error); + this.showErrorMessage(targets.container, `Error inicializando ${config.name}`); + } + } + + /** + * Re-renderizar módulo actual (ej: cuando cambia la vista) + */ + rerender(): void { + if (this.currentModule) { + this.currentModule.render(); + } + } + + /** + * Re-renderizar sidebar del módulo actual + */ + rerenderSidebar(): void { + if (this.currentModule) { + this.currentModule.renderSidebar(); + } + } + + /** + * Obtener módulo actual + */ + getCurrentModule(): BaseModule | null { + return this.currentModule; + } + + /** + * Obtener base actual + */ + getCurrentBase(): BaseType | null { + return this.currentBase; + } + + /** + * Unmount módulo actual + */ + unmount(): void { + this.currentModule?.unmount(); + this.currentModule = null; + this.currentBase = null; + } + + private showDisabledMessage(container: HTMLElement, moduleName: string): void { + container.innerHTML = ` +
+
🚧
+
${moduleName}
+
Próximamente
+
+ `; + } + + private showErrorMessage(container: HTMLElement, message: string): void { + container.innerHTML = ` +
+
⚠️
+
${message}
+
+ `; + } +} + +export default ModuleLoader; diff --git a/deck-frontend/backups/20260113_212146/src/modules/registry.ts b/deck-frontend/backups/20260113_212146/src/modules/registry.ts new file mode 100644 index 0000000..7940f0c --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/modules/registry.ts @@ -0,0 +1,170 @@ +/** + * Module Registry - Tipos e interfaces para el sistema modular + */ + +import type { Store } from '@/state/store.ts'; +import type { AppState, ViewType, BaseType } from '@/types/index.ts'; + +// Tipos de renderizado de módulos +export type ModuleRenderType = + | 'standard' // Grid/Tree/Graph normal (taxonomía, atc) + | 'chat' // Interfaz de chat con IA (mail, context) + | 'custom'; // Interfaz completamente custom (key, mindlink) + +// Categorías de módulos para agrupar en UI +export type ModuleCategory = + | 'taxonomy' // hst, flg, itm, loc, ply + | 'masters' // mst, bck + | 'registry' // atc, mth + | 'communication' // mail, chat + | 'services'; // key, mindlink + +// Configuración de vistas soportadas por módulo +export interface ModuleViews { + grid?: boolean; + tree?: boolean; + graph?: boolean; + custom?: string; // Nombre del componente custom a usar +} + +// Configuración de API por módulo +export interface ModuleApiConfig { + schema: string | null; // PostgREST schema (null = public) + table: string; // Tabla principal + hasLibraries?: boolean; // ¿Soporta bibliotecas? + hasGroups?: boolean; // ¿Soporta grupos (set_hst)? + hasGraph?: boolean; // ¿Tiene datos de grafo? + hasTree?: boolean; // ¿Tiene datos de árbol? +} + +// Configuración completa de un módulo +export interface BaseConfig { + id: BaseType; + name: string; // Nombre completo + shortName: string; // Para botón (3-4 chars) + category: ModuleCategory; + renderType: ModuleRenderType; + + // Vistas soportadas + views: ModuleViews; + defaultView: ViewType | 'custom'; + + // API + api: ModuleApiConfig; + + // Para módulos custom (lazy loading) + customModule?: () => Promise<{ default: new (ctx: ModuleContext) => BaseModule }>; + + // Estado inicial específico del módulo + initialState?: Partial; + + // Módulo habilitado (false = mostrar "Próximamente") + enabled?: boolean; +} + +// Estado específico de un módulo +export interface ModuleState { + loading: boolean; + error: string | null; + data: unknown; +} + +// Contexto pasado a cada módulo +export interface ModuleContext { + container: HTMLElement; + leftPanel: HTMLElement; + groupsBar: HTMLElement; + store: Store; + config: BaseConfig; + showDetail: (mrf: string) => void; +} + +// Clase base abstracta para módulos +export abstract class BaseModule { + protected ctx: ModuleContext; + protected mounted = false; + protected unsubscribe: (() => void) | null = null; + + constructor(ctx: ModuleContext) { + this.ctx = ctx; + } + + // Lifecycle + abstract mount(): Promise; + abstract unmount(): void; + abstract render(): void; + + // Override para carga de datos específica + async loadData(): Promise { + // Default: no hace nada, subclases implementan + } + + // Override para contenido del sidebar (libraries/options) + renderSidebar(): void { + // Default: vacío + this.ctx.leftPanel.innerHTML = ''; + } + + // Override para barra de grupos + renderGroupsBar(): void { + // Default: vacío + this.ctx.groupsBar.innerHTML = ''; + } + + // Helpers + protected getState(): Readonly { + return this.ctx.store.getState(); + } + + protected setState(partial: Partial): void { + this.ctx.store.setState(partial); + } + + protected getConfig(): BaseConfig { + return this.ctx.config; + } + + protected subscribe(listener: (state: AppState) => void): void { + this.unsubscribe = this.ctx.store.subscribe(listener); + } + + // Verificar si una vista está soportada + protected isViewSupported(view: ViewType): boolean { + return !!this.ctx.config.views[view]; + } +} + +// Helper para obtener config de módulo +export const getModuleConfig = (configs: Record, base: BaseType): BaseConfig => { + const config = configs[base]; + if (!config) { + throw new Error(`Module config not found for base: ${base}`); + } + return config; +}; + +// Helper para agrupar módulos por categoría +export const getModulesByCategory = ( + configs: Record +): Record => { + const result: Record = { + taxonomy: [], + masters: [], + registry: [], + communication: [], + services: [] + }; + + Object.values(configs).forEach(config => { + result[config.category].push(config); + }); + + return result; +}; + +// Helper para obtener módulos habilitados +export const getEnabledModules = ( + configs: Record +): BaseConfig[] => { + return Object.values(configs).filter(c => c.enabled !== false); +}; diff --git a/deck-frontend/backups/20260113_212146/src/modules/standard/StandardModule.ts b/deck-frontend/backups/20260113_212146/src/modules/standard/StandardModule.ts new file mode 100644 index 0000000..24c5d6e --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/modules/standard/StandardModule.ts @@ -0,0 +1,323 @@ +/** + * StandardModule - Módulo estándar para bases con vistas Grid/Tree/Graph + * + * Usado por: taxonomía (hst, flg, itm, loc, ply), maestros (mst, bck), + * registro (atc, mth) + */ + +import { BaseModule } from '../registry.ts'; +import { GridView, TreeView, GraphView } from '@/views/index.ts'; +import { fetchTags, fetchHstTags, fetchGroups, fetchLibraries, fetchLibraryMembers } from '@/api/index.ts'; +import { createNameMap, resolveGroupName, delegateEvent } from '@/utils/index.ts'; +import type { ViewType } from '@/types/index.ts'; + +export class StandardModule extends BaseModule { + private currentView: GridView | TreeView | GraphView | null = null; + + async mount(): Promise { + // Show loading + this.ctx.container.innerHTML = '
Cargando...
'; + + // Load data + await this.loadData(); + + // Render sidebar and groups + this.renderSidebar(); + this.renderGroupsBar(); + + // Render main view + this.render(); + + this.mounted = true; + } + + unmount(): void { + this.currentView?.unmount(); + this.currentView = null; + this.unsubscribe?.(); + this.unsubscribe = null; + this.mounted = false; + } + + async loadData(): Promise { + const config = this.getConfig(); + + // Fetch tags para esta base + const tags = await fetchTags(config.id); + + // Fetch HST tags para resolución de nombres de grupos (si tiene grupos) + const hstTags = config.api.hasGroups + ? await fetchHstTags() + : []; + + // Fetch grupos (solo si esta base los soporta) + const groups = config.api.hasGroups + ? await fetchGroups() + : []; + + // Fetch bibliotecas (solo si esta base las soporta) + const libraries = config.api.hasLibraries + ? await fetchLibraries(config.id) + : []; + + this.setState({ + tags, + hstTags, + groups, + libraries, + library: 'all', + libraryMembers: new Set(), + group: 'all' + }); + } + + render(): void { + const state = this.getState(); + const config = this.getConfig(); + + // Verificar que la vista está soportada + if (!this.isViewSupported(state.view)) { + // Cambiar a vista por defecto + const defaultView = config.defaultView as ViewType; + this.setState({ view: defaultView }); + return; + } + + // Unmount current view + this.currentView?.unmount(); + + // Clear container + this.ctx.container.innerHTML = ''; + this.ctx.container.className = `content-area ${state.view}-view`; + + // Mount new view + switch (state.view) { + case 'grid': + this.currentView = new GridView(this.ctx.container, this.ctx.store, this.ctx.showDetail); + this.currentView.mount(); + break; + + case 'tree': + if (config.views.tree) { + this.currentView = new TreeView(this.ctx.container, this.ctx.store, this.ctx.showDetail); + this.currentView.mount(); + } + break; + + case 'graph': + if (config.views.graph) { + this.currentView = new GraphView(this.ctx.container, this.ctx.store, this.ctx.showDetail); + (this.currentView as GraphView).mount(); + } + break; + } + } + + renderSidebar(): void { + const container = this.ctx.leftPanel; + const state = this.getState(); + const config = this.getConfig(); + + // Si es vista de grafo, mostrar opciones de grafo + if (state.view === 'graph' && config.views.graph) { + container.classList.add('graph-mode'); + this.renderGraphOptions(container); + return; + } + + container.classList.remove('graph-mode'); + + // Si no tiene bibliotecas, vaciar sidebar + if (!config.api.hasLibraries) { + container.innerHTML = ''; + return; + } + + // Ordenar bibliotecas alfabéticamente + const sortedLibs = [...state.libraries].sort((a, b) => { + const nameA = a.name || a.name_es || a.alias || a.ref || ''; + const nameB = b.name || b.name_es || b.alias || b.ref || ''; + return nameA.localeCompare(nameB); + }); + + // Renderizar bibliotecas (simple - sin config por ahora) + container.innerHTML = ` +
+ ALL +
+ ${sortedLibs.map(lib => { + const icon = lib.img_thumb_url || lib.icon_url || ''; + const name = lib.name || lib.name_es || lib.alias || lib.ref || lib.mrf.slice(0, 6); + return ` +
+ ${icon ? `` : ''} + ${name.slice(0, 8)} +
+ `; + }).join('')} + `; + + // Bind library clicks + delegateEvent(container, '.lib-icon', 'click', async (_, target) => { + const library = target.dataset.lib || 'all'; + + if (library === 'all') { + this.setState({ library: 'all', libraryMembers: new Set() }); + } else { + const currentBase = this.getState().base; + const members = await fetchLibraryMembers(library, currentBase); + this.setState({ library, libraryMembers: new Set(members) }); + } + + this.renderSidebar(); + this.render(); + }); + } + + renderGroupsBar(): void { + const container = this.ctx.groupsBar; + const state = this.getState(); + const config = this.getConfig(); + + // Si no tiene grupos, vaciar + if (!config.api.hasGroups) { + container.innerHTML = ''; + return; + } + + // Usar hstTags para resolución de nombres + const nameMap = createNameMap(state.hstTags, state.lang); + + // Contar tags por grupo + const counts = new Map(); + state.tags.forEach(tag => { + const group = tag.set_hst || 'sin-grupo'; + counts.set(group, (counts.get(group) || 0) + 1); + }); + + // Ordenar por count y tomar top 20 + const sorted = Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20); + + container.innerHTML = ` + + ${sorted.map(([groupMrf, count]) => { + const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap); + return ` + + `; + }).join('')} + `; + + // Bind group clicks + delegateEvent(container, '.group-btn', 'click', (_, target) => { + const group = target.dataset.group || 'all'; + this.setState({ group }); + this.renderGroupsBar(); + this.render(); + }); + } + + private renderGraphOptions(container: HTMLElement): void { + // TODO: Extraer a componente separado + const state = this.getState(); + const { graphSettings, tags, graphEdges } = state; + + container.innerHTML = ` +
+
+
+ Nodos + ${tags.length} +
+
+ Edges + ${graphEdges.length} +
+
+
+
Visualización
+ + +
+
+ Nodo + ${graphSettings.nodeSize}px +
+ +
+
+
+ Distancia + ${graphSettings.linkDist}px +
+ +
+
+
+ `; + + this.bindGraphOptionEvents(container); + } + + private bindGraphOptionEvents(container: HTMLElement): void { + // Show images checkbox + const showImgCb = container.querySelector('#graph-show-img'); + showImgCb?.addEventListener('change', () => { + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, showImg: showImgCb.checked } + }); + this.render(); + }); + + // Show labels checkbox + const showLblCb = container.querySelector('#graph-show-lbl'); + showLblCb?.addEventListener('change', () => { + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, showLbl: showLblCb.checked } + }); + this.render(); + }); + + // Node size slider + const nodeSizeSlider = container.querySelector('#graph-node-size'); + const nodeSizeVal = container.querySelector('#node-size-val'); + nodeSizeSlider?.addEventListener('input', () => { + const size = parseInt(nodeSizeSlider.value, 10); + if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`; + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, nodeSize: size } + }); + this.render(); + }); + + // Link distance slider + const linkDistSlider = container.querySelector('#graph-link-dist'); + const linkDistVal = container.querySelector('#link-dist-val'); + linkDistSlider?.addEventListener('input', () => { + const dist = parseInt(linkDistSlider.value, 10); + if (linkDistVal) linkDistVal.textContent = `${dist}px`; + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, linkDist: dist } + }); + this.render(); + }); + } +} + +export default StandardModule; diff --git a/deck-frontend/backups/20260113_212146/src/modules/standard/index.ts b/deck-frontend/backups/20260113_212146/src/modules/standard/index.ts new file mode 100644 index 0000000..d1cb866 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/modules/standard/index.ts @@ -0,0 +1 @@ +export { StandardModule, default } from './StandardModule.ts'; diff --git a/deck-frontend/backups/20260113_212146/src/router/index.ts b/deck-frontend/backups/20260113_212146/src/router/index.ts new file mode 100644 index 0000000..e6dd489 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/router/index.ts @@ -0,0 +1 @@ +export { Router } from './router.ts'; diff --git a/deck-frontend/backups/20260113_212146/src/router/router.ts b/deck-frontend/backups/20260113_212146/src/router/router.ts new file mode 100644 index 0000000..27a33a9 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/router/router.ts @@ -0,0 +1,62 @@ +import type { Store } from '@/state/store.ts'; +import type { AppState, BaseType, ViewType } from '@/types/index.ts'; + +const VALID_BASES: BaseType[] = ['hst', 'flg', 'itm', 'loc', 'ply']; +const VALID_VIEWS: ViewType[] = ['grid', 'tree', 'graph']; + +export class Router { + private store: Store; + private onNavigate: () => void; + + constructor(store: Store, onNavigate: () => void) { + this.store = store; + this.onNavigate = onNavigate; + + window.addEventListener('hashchange', () => this.handleHashChange()); + } + + parseHash(): void { + const hash = window.location.hash + .replace(/^#\/?/, '') + .replace(/\/?$/, '') + .split('/') + .filter(Boolean); + + const state = this.store.getState(); + let base = state.base; + let view = state.view; + + if (hash[0] && VALID_BASES.includes(hash[0].toLowerCase() as BaseType)) { + base = hash[0].toLowerCase() as BaseType; + } + + if (hash[1] && VALID_VIEWS.includes(hash[1].toLowerCase() as ViewType)) { + view = hash[1].toLowerCase() as ViewType; + } + + this.store.setState({ base, view }); + } + + updateHash(): void { + const state = this.store.getState(); + const parts: string[] = [state.base]; + if (state.view !== 'grid') { + parts.push(state.view); + } + window.location.hash = '/' + parts.join('/') + '/'; + } + + private handleHashChange(): void { + this.parseHash(); + this.onNavigate(); + } + + navigate(base?: BaseType, view?: ViewType): void { + const state = this.store.getState(); + this.store.setState({ + base: base ?? state.base, + view: view ?? state.view + }); + this.updateHash(); + } +} diff --git a/deck-frontend/backups/20260113_212146/src/state/index.ts b/deck-frontend/backups/20260113_212146/src/state/index.ts new file mode 100644 index 0000000..5d3a5c7 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/state/index.ts @@ -0,0 +1,35 @@ +import { createStore } from './store.ts'; +import type { AppState, EdgeType } from '@/types/index.ts'; +import { EDGE_COLORS } from '@/config/index.ts'; + +const initialState: AppState = { + base: 'hst', + lang: 'es', + view: 'grid', + search: '', + group: 'all', + library: 'all', + libraryMembers: new Set(), + selectionMode: false, + selected: new Set(), + selectedTag: null, + tags: [], + hstTags: [], + groups: [], + libraries: [], + graphEdges: [], + treeEdges: [], + graphFilters: { + cats: new Set(['hst'] as const), + edges: new Set(Object.keys(EDGE_COLORS) as EdgeType[]) + }, + graphSettings: { + nodeSize: 20, + linkDist: 80, + showImg: true, + showLbl: true + } +}; + +export const store = createStore(initialState); +export { createStore } from './store.ts'; diff --git a/deck-frontend/backups/20260113_212146/src/state/store.ts b/deck-frontend/backups/20260113_212146/src/state/store.ts new file mode 100644 index 0000000..865b837 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/state/store.ts @@ -0,0 +1,27 @@ +type Listener = (state: T, prevState: T) => void; + +export interface Store { + getState: () => Readonly; + setState: (partial: Partial) => void; + subscribe: (listener: Listener) => () => void; +} + +export function createStore(initialState: T): Store { + let state = { ...initialState }; + const listeners = new Set>(); + + return { + getState: (): Readonly => state, + + setState: (partial: Partial): void => { + const prevState = state; + state = { ...state, ...partial }; + listeners.forEach(fn => fn(state, prevState)); + }, + + subscribe: (listener: Listener): (() => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + } + }; +} diff --git a/deck-frontend/backups/20260113_212146/src/styles/main.css b/deck-frontend/backups/20260113_212146/src/styles/main.css new file mode 100644 index 0000000..84d3791 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/styles/main.css @@ -0,0 +1,701 @@ +/* === RESET & VARIABLES === */ +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #0a0a0f; + --bg-secondary: #12121a; + --bg-card: #1a1a24; + --border: #2a2a3a; + --text: #e0e0e0; + --text-muted: #888; + --accent: #7c8aff; + --card-width: 176px; + --card-img-height: 176px; +} + +html, body { height: 100%; overflow: hidden; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: var(--bg-secondary); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; } +::-webkit-scrollbar-thumb:hover { background: #444; } + +/* === APP LAYOUT === */ +.app { display: flex; flex-direction: column; height: 100vh; } + +/* === TOPBAR === */ +.topbar { + height: 50px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 16px; + gap: 12px; +} +.topbar-left { display: flex; align-items: center; gap: 10px; } +.topbar-center { flex: 1; display: flex; justify-content: center; gap: 16px; } +.topbar-right { display: flex; align-items: center; gap: 10px; } +.logo { font-weight: 700; font-size: 1.2em; color: var(--accent); letter-spacing: 1px; } + +/* === BUTTONS === */ +.btn { + padding: 7px 14px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.8em; + font-weight: 500; + transition: all 0.15s ease; +} +.btn:hover { border-color: var(--accent); color: var(--text); } +.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-sm { padding: 5px 10px; font-size: 0.75em; } + +.search-input { + width: 300px; + padding: 9px 14px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 0.9em; +} +.search-input:focus { outline: none; border-color: var(--accent); } +.search-input::placeholder { color: var(--text-muted); } + +.base-buttons { display: flex; gap: 2px; background: var(--bg-card); border-radius: 6px; padding: 3px; } +.base-btn { + padding: 6px 14px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.8em; + font-weight: 600; + transition: all 0.15s ease; +} +.base-btn:hover { color: var(--text); } +.base-btn.active { background: var(--accent); color: #fff; } + +/* === VIEW BAR === */ +.view-bar { + height: 40px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; +} +.view-bar-spacer { width: 120px; } + +/* === SEL/GET GROUP === */ +.sel-group { + display: flex; + align-items: center; + gap: 2px; + background: var(--bg-card); + border-radius: 6px; + padding: 3px; +} +.sel-btn { + padding: 5px 12px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.75em; + font-weight: 600; + transition: all 0.15s ease; +} +.sel-btn:hover { color: var(--text); background: rgba(255,255,255,0.05); } +.sel-btn.active { background: var(--accent); color: #fff; } +#sel-count { + font-size: 0.7em; + color: var(--accent); + margin-left: 6px; + font-weight: 600; +} + +/* === GROUPS BAR === */ +.groups-bar { + height: 44px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 16px; + gap: 8px; + overflow-x: auto; +} +.group-btn { + padding: 6px 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 20px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.75em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + transition: all 0.15s ease; +} +.group-btn:hover { border-color: var(--accent); color: var(--text); } +.group-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +/* === MAIN LAYOUT === */ +.main-layout { display: flex; flex: 1; overflow: hidden; } + +/* === LEFT PANEL === */ +.left-panel { + width: 84px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + overflow-y: auto; + padding: 10px 6px; + flex-shrink: 0; +} +.lib-icon { + width: 68px; + height: 68px; + margin: 6px auto; + border-radius: 10px; + background: var(--bg-card); + border: 2px solid transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + transition: all 0.15s ease; + overflow: hidden; +} +.lib-icon:hover { border-color: var(--accent); } +.lib-icon.active { border-color: var(--accent); background: rgba(124, 138, 255, 0.15); } +.lib-icon img { width: 42px; height: 42px; object-fit: cover; border-radius: 6px; } +.lib-icon span { + font-size: 0.6em; + color: var(--text-muted); + margin-top: 4px; + text-align: center; + max-width: 60px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* === CENTER PANEL === */ +.center-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; } + +/* === VIEW TABS === */ +.view-tabs { display: flex; gap: 6px; } +.view-tab { + padding: 7px 20px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.85em; + font-weight: 500; + transition: all 0.15s ease; +} +.view-tab:hover { color: var(--text); background: var(--bg-card); } +.view-tab.active { background: var(--accent); color: #fff; } + +/* === CONTENT AREA === */ +.content-area { flex: 1; overflow: hidden; position: relative; } + +/* === GRID VIEW === */ +.grid-view { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 16px; + padding: 20px; + overflow-y: auto; + height: 100%; +} + +.card { + width: var(--card-width); + flex-shrink: 0; + flex-grow: 0; + background: var(--bg-card); + border-radius: 10px; + border: 1px solid var(--border); + overflow: hidden; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; + position: relative; +} +.card:hover { + border-color: var(--accent); + transform: translateY(-3px); + box-shadow: 0 8px 20px rgba(0,0,0,0.3); +} +.card.selected { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(124, 138, 255, 0.4); +} + +.card-checkbox { + position: absolute; + top: 10px; + left: 10px; + width: 24px; + height: 24px; + border-radius: 6px; + background: rgba(0,0,0,0.7); + border: 2px solid var(--border); + display: none; + align-items: center; + justify-content: center; + z-index: 5; + transition: all 0.15s ease; +} +.card-checkbox.visible { display: flex; } +.card-checkbox.checked { background: var(--accent); border-color: var(--accent); } +.card-checkbox.checked::after { content: "\2713"; color: #fff; font-size: 14px; font-weight: bold; } + +.card-image { + width: var(--card-width); + height: var(--card-img-height); + background: linear-gradient(145deg, #1a1a24 0%, #0a0a0f 100%); + position: relative; + overflow: hidden; +} + +.card-placeholder { + width: var(--card-width); + height: var(--card-img-height); + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5em; + font-weight: 700; + color: var(--accent); + opacity: 0.5; + text-transform: uppercase; + background: linear-gradient(145deg, #1a1a24 0%, #0a0a0f 100%); +} + +.card-img { + width: var(--card-width); + height: var(--card-img-height); + object-fit: cover; +} + +.card-body { padding: 12px; } +.card-ref { + font-size: 0.75em; + color: var(--accent); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.card-name { + font-size: 0.85em; + color: var(--text); + margin-top: 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; +} + +/* === TREE VIEW === */ +.tree-view { + padding: 20px; + overflow-y: auto; + height: 100%; +} +.tree-group { margin-bottom: 12px; } +.tree-header { + display: flex; + align-items: center; + padding: 10px 12px; + cursor: pointer; + border-radius: 8px; + background: var(--bg-card); + transition: background 0.15s ease; +} +.tree-header:hover { background: var(--bg-secondary); } +.tree-toggle { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 1em; + font-weight: bold; + flex-shrink: 0; +} +.tree-group-name { flex: 1; font-weight: 500; } +.tree-count { + font-size: 0.75em; + color: var(--text-muted); + background: var(--bg-secondary); + padding: 4px 10px; + border-radius: 12px; +} +.tree-items { display: none; margin-left: 28px; margin-top: 4px; } +.tree-items.expanded { display: block; } +.tree-item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + border-radius: 6px; + margin: 3px 0; + gap: 10px; + transition: background 0.15s ease; +} +.tree-item:hover { background: var(--bg-card); } +.tree-item.selected { background: rgba(124,138,255,0.15); } +.tree-img { + width: 32px; + height: 32px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; + background: var(--bg-card); +} +.tree-placeholder { + width: 32px; + height: 32px; + border-radius: 6px; + background: var(--bg-card); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9em; + font-weight: 600; + color: var(--accent); +} +.tree-name { font-size: 0.9em; } + +/* === GRAPH VIEW === */ +.graph-view { + width: 100%; + height: 100%; + position: relative; +} +.graph-view svg { width: 100%; height: 100%; display: block; background: var(--bg); } +.node { cursor: pointer; } +.node text { fill: var(--text-muted); pointer-events: none; font-size: 11px; } +.node.selected circle { stroke: var(--accent); stroke-width: 4; } +.link { stroke-opacity: 0.5; } + +/* === DETAIL PANEL === */ +.detail-panel { + width: 0; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + overflow-y: auto; + overflow-x: hidden; + transition: width 0.3s ease; + flex-shrink: 0; +} +.detail-panel.open { width: 360px; } + +.detail-header { + position: relative; + width: 100%; + height: 220px; + background: linear-gradient(145deg, var(--bg-card) 0%, var(--bg) 100%); + overflow: hidden; +} +.detail-placeholder { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 5em; + font-weight: 700; + color: var(--accent); + opacity: 0.4; + text-transform: uppercase; +} +.detail-img { + width: 100%; + height: 100%; + object-fit: cover; +} +.detail-close { + position: absolute; + top: 12px; + right: 12px; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(0,0,0,0.7); + border: none; + color: #fff; + cursor: pointer; + font-size: 20px; + z-index: 5; + transition: background 0.15s ease; +} +.detail-close:hover { background: rgba(0,0,0,0.9); } + +.detail-body { padding: 20px; } +.detail-ref { + font-size: 1.2em; + color: var(--accent); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; +} +.detail-mrf { + font-size: 0.7em; + color: var(--text-muted); + margin-top: 8px; + font-family: monospace; + word-break: break-all; + cursor: pointer; + padding: 8px 10px; + background: var(--bg-card); + border-radius: 6px; + transition: color 0.15s ease; +} +.detail-mrf:hover { color: var(--accent); } +.detail-name { font-size: 1.3em; color: var(--text); margin-top: 16px; font-weight: 500; } +.detail-desc { font-size: 0.9em; color: var(--text-muted); margin-top: 12px; line-height: 1.7; } + +.detail-section { margin-top: 24px; } +.detail-section h4 { + font-size: 0.75em; + color: var(--text-muted); + text-transform: uppercase; + margin-bottom: 12px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.chip-list { display: flex; flex-wrap: wrap; gap: 8px; } +.tag-chip { + padding: 7px 12px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.8em; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s ease; +} +.tag-chip:hover { border-color: var(--accent); color: var(--text); } + +/* === TOAST === */ +.toast { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: var(--accent); + color: #fff; + padding: 14px 28px; + border-radius: 10px; + font-size: 0.9em; + font-weight: 500; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 1000; + pointer-events: none; +} +.toast.show { opacity: 1; } + +/* === MODAL === */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.85); + display: none; + align-items: center; + justify-content: center; + z-index: 100; +} +.modal.open { display: flex; } +.modal-content { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 14px; + width: 90%; + max-width: 620px; + max-height: 80vh; + overflow: hidden; +} +.modal-header { + padding: 18px 22px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} +.modal-header h3 { font-size: 1.15em; color: var(--text); font-weight: 600; } +.modal-close { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1.5em; + padding: 4px 8px; +} +.modal-close:hover { color: var(--text); } +.modal-body { padding: 22px; overflow-y: auto; max-height: calc(80vh - 65px); } +.api-item { margin-bottom: 18px; } +.api-endpoint { + font-family: monospace; + font-size: 0.9em; + color: var(--accent); + background: var(--bg-card); + padding: 12px 14px; + border-radius: 8px; +} +.api-desc { font-size: 0.85em; color: var(--text-muted); margin-top: 8px; } + +/* === EMPTY STATE === */ +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + gap: 16px; + padding: 40px; +} + +/* === LOADING === */ +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + gap: 14px; + font-size: 1em; +} +.loading::after { + content: ""; + width: 28px; + height: 28px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* === SELECT === */ +select { + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-card); + color: var(--text); + font-size: 0.8em; + cursor: pointer; +} +select:focus { outline: none; border-color: var(--accent); } + +/* === GRAPH OPTIONS PANEL === */ +.graph-options { + padding: 10px; + overflow-y: auto; + width: 180px; +} +.graph-section { + margin-bottom: 16px; +} +.graph-section-title { + font-size: 0.7em; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} +.graph-stat { + display: flex; + justify-content: space-between; + font-size: 0.75em; + color: var(--text-muted); + margin-bottom: 4px; +} +.graph-stat-value { + color: var(--text); + font-weight: 600; +} +.graph-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.75em; + color: var(--text-muted); + margin-bottom: 6px; + cursor: pointer; +} +.graph-checkbox:hover { color: var(--text); } +.graph-checkbox input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--accent); + cursor: pointer; +} +.graph-checkbox .color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 4px; +} +.graph-slider { + margin-bottom: 12px; +} +.graph-slider-label { + display: flex; + justify-content: space-between; + font-size: 0.7em; + color: var(--text-muted); + margin-bottom: 4px; +} +.graph-slider-value { + color: var(--text); + font-weight: 600; +} +.graph-slider input[type="range"] { + width: 100%; + height: 4px; + background: var(--border); + border-radius: 2px; + -webkit-appearance: none; + cursor: pointer; +} +.graph-slider input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; +} +.left-panel.graph-mode { + width: 180px; +} diff --git a/deck-frontend/backups/20260113_212146/src/types/graph.ts b/deck-frontend/backups/20260113_212146/src/types/graph.ts new file mode 100644 index 0000000..ceaaa4b --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/types/graph.ts @@ -0,0 +1,37 @@ +export type EdgeType = + | 'relation' + | 'specialization' + | 'mirror' + | 'dependency' + | 'sequence' + | 'composition' + | 'hierarchy' + | 'library' + | 'contextual' + | 'association'; + +export type CategoryKey = 'hst' | 'spe' | 'vue' | 'vsn' | 'msn' | 'flg'; + +export interface GraphEdge { + mrf_a: string; + mrf_b: string; + edge_type: EdgeType; + weight?: number; +} + +export interface TreeEdge { + mrf_parent: string; + mrf_child: string; +} + +export interface GraphNode { + id: string; + ref: string; + name: string; + img: string; + cat: CategoryKey; + x?: number; + y?: number; + fx?: number | null; + fy?: number | null; +} diff --git a/deck-frontend/backups/20260113_212146/src/types/index.ts b/deck-frontend/backups/20260113_212146/src/types/index.ts new file mode 100644 index 0000000..5d577bd --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/types/index.ts @@ -0,0 +1,16 @@ +export type { Tag, Group, Library, ChildTag, RelatedTag } from './tag.ts'; +export type { + EdgeType, + CategoryKey, + GraphEdge, + TreeEdge, + GraphNode +} from './graph.ts'; +export type { + ViewType, + BaseType, + LangType, + GraphFilters, + GraphSettings, + AppState +} from './state.ts'; diff --git a/deck-frontend/backups/20260113_212146/src/types/state.ts b/deck-frontend/backups/20260113_212146/src/types/state.ts new file mode 100644 index 0000000..0fc8a07 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/types/state.ts @@ -0,0 +1,53 @@ +import type { Tag, Group, Library } from './tag.ts'; +import type { GraphEdge, TreeEdge, CategoryKey, EdgeType } from './graph.ts'; + +export type ViewType = 'grid' | 'tree' | 'graph'; +export type BaseType = + | 'hst' | 'flg' | 'itm' | 'loc' | 'ply' // Taxonomía (public) + | 'mth' | 'atc' // Registro (secretaria_clara, production_alfred) + | 'mst' | 'bck' // Maestros (secretaria_clara) + | 'mail' | 'chat' // Comunicación (mail_manager, context_manager) + | 'key' | 'mindlink'; // Servicios +export type LangType = 'es' | 'en' | 'ch'; + +export interface GraphFilters { + cats: Set; + edges: Set; +} + +export interface GraphSettings { + nodeSize: number; + linkDist: number; + showImg: boolean; + showLbl: boolean; +} + +export interface AppState { + // Navigation + base: BaseType; + lang: LangType; + view: ViewType; + + // Filters + search: string; + group: string; + library: string; + libraryMembers: Set; + + // Selection + selectionMode: boolean; + selected: Set; + selectedTag: Tag | null; + + // Data + tags: Tag[]; + hstTags: Tag[]; // HST tags for group name resolution + groups: Group[]; + libraries: Library[]; + graphEdges: GraphEdge[]; + treeEdges: TreeEdge[]; + + // Graph-specific + graphFilters: GraphFilters; + graphSettings: GraphSettings; +} diff --git a/deck-frontend/backups/20260113_212146/src/types/tag.ts b/deck-frontend/backups/20260113_212146/src/types/tag.ts new file mode 100644 index 0000000..425df07 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/types/tag.ts @@ -0,0 +1,42 @@ +export interface Tag { + mrf: string; + ref: string; + name_es?: string; + name_en?: string; + name_ch?: string; + txt?: string; + alias?: string; + set_hst?: string; + img_url?: string; + img_thumb_url?: string; +} + +export interface Group { + mrf: string; + ref: string; + name_es?: string; + name_en?: string; +} + +export interface Library { + mrf: string; + ref?: string; + name?: string; + name_es?: string; + name_en?: string; + alias?: string; + icon_url?: string; + img_thumb_url?: string; + member_count?: number; +} + +export interface ChildTag { + mrf: string; + ref?: string; + alias?: string; + name_es?: string; +} + +export interface RelatedTag extends ChildTag { + edge_type: string; +} diff --git a/deck-frontend/backups/20260113_212146/src/utils/clipboard.ts b/deck-frontend/backups/20260113_212146/src/utils/clipboard.ts new file mode 100644 index 0000000..ab117d1 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/utils/clipboard.ts @@ -0,0 +1,14 @@ +import { toast } from './toast.ts'; + +export async function copyToClipboard(text: string, message?: string): Promise { + try { + await navigator.clipboard.writeText(text); + toast(message || 'Copiado'); + } catch { + toast('Error al copiar'); + } +} + +export function copyMrf(mrf: string): void { + copyToClipboard(mrf, `MRF copiado: ${mrf.slice(0, 8)}...`); +} diff --git a/deck-frontend/backups/20260113_212146/src/utils/dom.ts b/deck-frontend/backups/20260113_212146/src/utils/dom.ts new file mode 100644 index 0000000..50cfa99 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/utils/dom.ts @@ -0,0 +1,44 @@ +export const $ = ( + selector: string, + parent: ParentNode = document +): T | null => parent.querySelector(selector); + +export const $$ = ( + selector: string, + parent: ParentNode = document +): T[] => Array.from(parent.querySelectorAll(selector)); + +export function createElement( + tag: K, + attrs?: Record, + children?: (HTMLElement | string)[] +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + if (attrs) { + Object.entries(attrs).forEach(([key, value]) => { + if (key === 'className') el.className = value; + else if (key.startsWith('data-')) el.setAttribute(key, value); + else el.setAttribute(key, value); + }); + } + if (children) { + children.forEach(child => { + el.append(typeof child === 'string' ? child : child); + }); + } + return el; +} + +export function delegateEvent( + container: HTMLElement, + selector: string, + eventType: string, + handler: (event: T, target: HTMLElement) => void +): void { + container.addEventListener(eventType, (event) => { + const target = (event.target as HTMLElement).closest(selector); + if (target && container.contains(target)) { + handler(event as T, target); + } + }); +} diff --git a/deck-frontend/backups/20260113_212146/src/utils/filters.ts b/deck-frontend/backups/20260113_212146/src/utils/filters.ts new file mode 100644 index 0000000..b192126 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/utils/filters.ts @@ -0,0 +1,39 @@ +import type { Tag, LangType } from '@/types/index.ts'; +import { getName } from './i18n.ts'; + +export interface FilterOptions { + search: string; + group: string; + library: string; + libraryMembers: Set; + lang: LangType; +} + +export function filterTags(tags: Tag[], options: FilterOptions): Tag[] { + const { search, group, library, libraryMembers, lang } = options; + const q = search.toLowerCase(); + + return tags.filter(tag => { + // Library filter + if (library !== 'all' && !libraryMembers.has(tag.mrf)) { + return false; + } + + // Group filter + if (group !== 'all' && tag.set_hst !== group) { + return false; + } + + // Search filter + if (q) { + const name = getName(tag, lang).toLowerCase(); + const ref = (tag.ref || '').toLowerCase(); + const alias = (tag.alias || '').toLowerCase(); + if (!name.includes(q) && !ref.includes(q) && !alias.includes(q)) { + return false; + } + } + + return true; + }); +} diff --git a/deck-frontend/backups/20260113_212146/src/utils/i18n.ts b/deck-frontend/backups/20260113_212146/src/utils/i18n.ts new file mode 100644 index 0000000..1d1b8d2 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/utils/i18n.ts @@ -0,0 +1,42 @@ +import type { Tag, LangType } from '@/types/index.ts'; + +export function getName(tag: Tag, lang: LangType): string { + if (lang === 'es' && tag.name_es) return tag.name_es; + if (lang === 'en' && tag.name_en) return tag.name_en; + if (lang === 'ch' && tag.name_ch) return tag.name_ch; + return tag.name_es || tag.name_en || tag.alias || tag.ref || tag.mrf.slice(0, 8); +} + +// Create a map of mrf -> display name for resolving groups +export function createNameMap(tags: Tag[], lang: LangType): Map { + const map = new Map(); + tags.forEach(tag => { + map.set(tag.mrf, getName(tag, lang)); + }); + return map; +} + +// Resolve a group mrf to its display name +export function resolveGroupName(mrf: string | undefined, nameMap: Map): string { + if (!mrf) return 'Sin grupo'; + return nameMap.get(mrf) || mrf.slice(0, 8); +} + +const ATC_BASE = 'https://atc.tzzrdeck.me'; + +function resolveImgUrl(url: string | undefined): string { + if (!url) return ''; + // Relative paths (e.g., "thumbs/xxx.png") need ATC base + if (url && !url.startsWith('http')) { + return `${ATC_BASE}/${url}`; + } + return url; +} + +export function getImg(tag: Tag): string { + return resolveImgUrl(tag.img_thumb_url); +} + +export function getFullImg(tag: Tag): string { + return resolveImgUrl(tag.img_url) || resolveImgUrl(tag.img_thumb_url); +} diff --git a/deck-frontend/backups/20260113_212146/src/utils/index.ts b/deck-frontend/backups/20260113_212146/src/utils/index.ts new file mode 100644 index 0000000..a0131b6 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/utils/index.ts @@ -0,0 +1,5 @@ +export { $, $$, createElement, delegateEvent } from './dom.ts'; +export { getName, getImg, getFullImg, createNameMap, resolveGroupName } from './i18n.ts'; +export { filterTags, type FilterOptions } from './filters.ts'; +export { copyToClipboard, copyMrf } from './clipboard.ts'; +export { toast } from './toast.ts'; diff --git a/deck-frontend/backups/20260113_212146/src/utils/toast.ts b/deck-frontend/backups/20260113_212146/src/utils/toast.ts new file mode 100644 index 0000000..e5258ee --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/utils/toast.ts @@ -0,0 +1,21 @@ +let toastEl: HTMLElement | null = null; +let toastTimeout: number | null = null; + +export function toast(message: string, duration = 2000): void { + if (!toastEl) { + toastEl = document.createElement('div'); + toastEl.className = 'toast'; + document.body.appendChild(toastEl); + } + + if (toastTimeout) { + clearTimeout(toastTimeout); + } + + toastEl.textContent = message; + toastEl.classList.add('show'); + + toastTimeout = window.setTimeout(() => { + toastEl?.classList.remove('show'); + }, duration); +} diff --git a/deck-frontend/backups/20260113_212146/src/views/DetailPanel/DetailPanel.ts b/deck-frontend/backups/20260113_212146/src/views/DetailPanel/DetailPanel.ts new file mode 100644 index 0000000..70833bf --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/views/DetailPanel/DetailPanel.ts @@ -0,0 +1,115 @@ +import { View } from '../View.ts'; +import { getName, getFullImg, copyMrf, delegateEvent } from '@/utils/index.ts'; +import { fetchChildren, fetchRelated } from '@/api/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, Tag } from '@/types/index.ts'; + +export class DetailPanel extends View { + private panelEl: HTMLElement; + + constructor( + container: HTMLElement, + store: Store + ) { + super(container, store); + this.panelEl = container; + } + + async showDetail(mrf: string): Promise { + const state = this.getState(); + const tag = state.tags.find(t => t.mrf === mrf); + if (!tag) return; + + this.setState({ selectedTag: tag }); + this.panelEl.classList.add('open'); + await this.renderDetail(tag); + } + + close(): void { + this.panelEl.classList.remove('open'); + this.setState({ selectedTag: null }); + } + + render(): void { + const state = this.getState(); + if (state.selectedTag) { + this.renderDetail(state.selectedTag); + } + } + + private async renderDetail(tag: Tag): Promise { + const state = this.getState(); + const img = getFullImg(tag); + const name = getName(tag, state.lang); + + this.panelEl.innerHTML = ` +
+ ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 2) || 'T'}
` + } + +
+
+
${tag.ref || ''}
+
${tag.mrf}
+
${name}
+
${tag.txt || tag.alias || ''}
+ + +
+ `; + + this.bindDetailEvents(); + await this.loadRelations(tag.mrf); + } + + private bindDetailEvents(): void { + const closeBtn = this.panelEl.querySelector('.detail-close'); + closeBtn?.addEventListener('click', () => this.close()); + + const mrfEl = this.panelEl.querySelector('.detail-mrf'); + mrfEl?.addEventListener('click', () => { + const mrf = (mrfEl as HTMLElement).dataset.mrf; + if (mrf) copyMrf(mrf); + }); + + delegateEvent(this.panelEl, '.tag-chip', 'click', (_, target) => { + const mrf = target.dataset.mrf; + if (mrf) this.showDetail(mrf); + }); + } + + private async loadRelations(mrf: string): Promise { + const [children, related] = await Promise.all([ + fetchChildren(mrf), + fetchRelated(mrf) + ]); + + const childrenSection = this.panelEl.querySelector('#children-section') as HTMLElement; + const childrenList = this.panelEl.querySelector('#children-list') as HTMLElement; + if (children.length > 0) { + childrenSection.style.display = 'block'; + childrenList.innerHTML = children.map(c => { + const label = c.name_es || c.alias || c.ref || c.mrf.slice(0, 8); + return `${label}`; + }).join(''); + } + + const relatedSection = this.panelEl.querySelector('#related-section') as HTMLElement; + const relatedList = this.panelEl.querySelector('#related-list') as HTMLElement; + if (related.length > 0) { + relatedSection.style.display = 'block'; + relatedList.innerHTML = related.map(r => { + const label = r.name_es || r.alias || r.ref || r.mrf.slice(0, 8); + return `${label}`; + }).join(''); + } + } +} diff --git a/deck-frontend/backups/20260113_212146/src/views/GraphView/GraphView.ts b/deck-frontend/backups/20260113_212146/src/views/GraphView/GraphView.ts new file mode 100644 index 0000000..b708ca9 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/views/GraphView/GraphView.ts @@ -0,0 +1,249 @@ +import { View } from '../View.ts'; +import { filterTags, getName, getImg } from '@/utils/index.ts'; +import { fetchGraphEdges, fetchTreeEdges } from '@/api/index.ts'; +import { CATS, EDGE_COLORS } from '@/config/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, GraphNode, CategoryKey, EdgeType } from '@/types/index.ts'; + +type D3Module = typeof import('d3'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type D3Selection = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type D3Simulation = any; + +export class GraphView extends View { + private d3: D3Module | null = null; + private simulation: D3Simulation | null = null; + private showDetail: (mrf: string) => void; + + constructor( + container: HTMLElement, + store: Store, + showDetail: (mrf: string) => void + ) { + super(container, store); + this.showDetail = showDetail; + } + + async mount(): Promise { + this.container.innerHTML = '
Cargando grafo...
'; + + // Lazy load D3 + if (!this.d3) { + this.d3 = await import('d3'); + } + + // Load graph data + const state = this.getState(); + if (state.graphEdges.length === 0) { + const [graphEdges, treeEdges] = await Promise.all([ + fetchGraphEdges(), + fetchTreeEdges() + ]); + this.store.setState({ graphEdges, treeEdges }); + } + + this.render(); + } + + render(): void { + if (!this.d3) return; + const d3 = this.d3; + const state = this.getState(); + + // Build nodes from filtered tags + const filtered = filterTags(state.tags, { + search: state.search, + group: state.group, + library: state.library, + libraryMembers: state.libraryMembers, + lang: state.lang + }); + + const nodeMap = new Map(); + filtered.forEach(tag => { + nodeMap.set(tag.mrf, { + id: tag.mrf, + ref: tag.alias || tag.ref || tag.mrf.slice(0, 8), + name: getName(tag, state.lang), + img: getImg(tag), + cat: 'hst' as CategoryKey + }); + }); + + // Build edges + interface GraphLink { + source: string | GraphNode; + target: string | GraphNode; + type: EdgeType; + weight: number; + } + const edges: GraphLink[] = []; + + state.graphEdges.forEach(e => { + if (nodeMap.has(e.mrf_a) && nodeMap.has(e.mrf_b)) { + if (state.graphFilters.edges.has(e.edge_type)) { + edges.push({ + source: e.mrf_a, + target: e.mrf_b, + type: e.edge_type, + weight: e.weight || 1 + }); + } + } + }); + + state.treeEdges.forEach(e => { + if (nodeMap.has(e.mrf_parent) && nodeMap.has(e.mrf_child)) { + if (state.graphFilters.edges.has('hierarchy')) { + edges.push({ + source: e.mrf_parent, + target: e.mrf_child, + type: 'hierarchy', + weight: 1 + }); + } + } + }); + + const nodes = Array.from(nodeMap.values()); + + if (nodes.length === 0) { + this.container.innerHTML = '
Sin nodos para mostrar
'; + return; + } + + // Clear and create SVG + this.container.innerHTML = ''; + const width = this.container.clientWidth; + const height = this.container.clientHeight || 600; + + const svg: D3Selection = d3.select(this.container) + .append('svg') + .attr('width', '100%') + .attr('height', '100%') + .attr('viewBox', `0 0 ${width} ${height}`); + + const g = svg.append('g'); + + // Zoom + const zoom = d3.zoom() + .scaleExtent([0.1, 4]) + .on('zoom', (event: { transform: string }) => { + g.attr('transform', event.transform); + }); + + svg.call(zoom); + + // Simulation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.simulation = d3.forceSimulation(nodes as any) + .force('link', d3.forceLink(edges) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .id((d: any) => d.id) + .distance(state.graphSettings.linkDist)) + .force('charge', d3.forceManyBody().strength(-150)) + .force('center', d3.forceCenter(width / 2, height / 2)) + .force('collision', d3.forceCollide().radius(state.graphSettings.nodeSize + 5)); + + // Links + const link = g.append('g') + .selectAll('line') + .data(edges) + .join('line') + .attr('stroke', (d: GraphLink) => EDGE_COLORS[d.type] || '#999') + .attr('stroke-width', (d: GraphLink) => Math.sqrt(d.weight)) + .attr('stroke-opacity', 0.6); + + // Nodes + const node = g.append('g') + .selectAll('g') + .data(nodes) + .join('g') + .attr('cursor', 'pointer') + .call(this.createDrag(d3, this.simulation)); + + const nodeSize = state.graphSettings.nodeSize; + + if (state.graphSettings.showImg) { + node.append('image') + .attr('xlink:href', (d: GraphNode) => d.img || '') + .attr('width', nodeSize) + .attr('height', nodeSize) + .attr('x', -nodeSize / 2) + .attr('y', -nodeSize / 2) + .attr('clip-path', 'circle(50%)'); + + // Fallback for nodes without image + node.filter((d: GraphNode) => !d.img) + .append('circle') + .attr('r', nodeSize / 2) + .attr('fill', (d: GraphNode) => CATS[d.cat]?.color || '#7c8aff'); + } else { + node.append('circle') + .attr('r', nodeSize / 2) + .attr('fill', (d: GraphNode) => CATS[d.cat]?.color || '#7c8aff'); + } + + if (state.graphSettings.showLbl) { + node.append('text') + .text((d: GraphNode) => d.ref) + .attr('dy', nodeSize / 2 + 12) + .attr('text-anchor', 'middle') + .attr('font-size', 10) + .attr('fill', 'var(--text-primary)'); + } + + node.on('click', (_: MouseEvent, d: GraphNode) => { + if (state.selectionMode) { + const newSelected = new Set(state.selected); + if (newSelected.has(d.id)) { + newSelected.delete(d.id); + } else { + newSelected.add(d.id); + } + this.store.setState({ selected: newSelected }); + } else { + this.showDetail(d.id); + } + }); + + // Tick + this.simulation.on('tick', () => { + link + .attr('x1', (d: GraphLink) => (d.source as GraphNode).x!) + .attr('y1', (d: GraphLink) => (d.source as GraphNode).y!) + .attr('x2', (d: GraphLink) => (d.target as GraphNode).x!) + .attr('y2', (d: GraphLink) => (d.target as GraphNode).y!); + + node.attr('transform', (d: GraphNode) => `translate(${d.x},${d.y})`); + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private createDrag(d3: D3Module, simulation: D3Simulation): any { + return d3.drag() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .on('start', (event: any, d: any) => { + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .on('drag', (event: any, d: any) => { + d.fx = event.x; + d.fy = event.y; + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .on('end', (event: any, d: any) => { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + }); + } + + unmount(): void { + this.simulation?.stop(); + super.unmount(); + } +} diff --git a/deck-frontend/backups/20260113_212146/src/views/GridView/GridView.ts b/deck-frontend/backups/20260113_212146/src/views/GridView/GridView.ts new file mode 100644 index 0000000..48eef00 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/views/GridView/GridView.ts @@ -0,0 +1,75 @@ +import { View } from '../View.ts'; +import { filterTags, getName, getImg, delegateEvent } from '@/utils/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState } from '@/types/index.ts'; + +export class GridView extends View { + private showDetail: (mrf: string) => void; + + constructor( + container: HTMLElement, + store: Store, + showDetail: (mrf: string) => void + ) { + super(container, store); + this.showDetail = showDetail; + } + + render(): void { + const state = this.getState(); + const filtered = filterTags(state.tags, { + search: state.search, + group: state.group, + library: state.library, + libraryMembers: state.libraryMembers, + lang: state.lang + }); + + if (filtered.length === 0) { + this.container.innerHTML = '
Sin resultados
'; + return; + } + + this.container.innerHTML = filtered.map(tag => { + const img = getImg(tag); + const name = getName(tag, state.lang); + const isSelected = state.selected.has(tag.mrf); + + return ` +
+ ${state.selectionMode ? ` + + ` : ''} + ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 2) || 'T'}
` + } +
${name}
+
+ `; + }).join(''); + + this.bindEvents(); + } + + private bindEvents(): void { + const state = this.getState(); + + delegateEvent(this.container, '.card', 'click', (_, target) => { + const mrf = target.dataset.mrf; + if (!mrf) return; + + if (state.selectionMode) { + const newSelected = new Set(state.selected); + if (newSelected.has(mrf)) { + newSelected.delete(mrf); + } else { + newSelected.add(mrf); + } + this.setState({ selected: newSelected }); + } else { + this.showDetail(mrf); + } + }); + } +} diff --git a/deck-frontend/backups/20260113_212146/src/views/TreeView/TreeView.ts b/deck-frontend/backups/20260113_212146/src/views/TreeView/TreeView.ts new file mode 100644 index 0000000..9581c5d --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/views/TreeView/TreeView.ts @@ -0,0 +1,109 @@ +import { View } from '../View.ts'; +import { filterTags, getName, getImg, delegateEvent, createNameMap, resolveGroupName } from '@/utils/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, Tag } from '@/types/index.ts'; + +export class TreeView extends View { + private showDetail: (mrf: string) => void; + private expanded: Set = new Set(); + + constructor( + container: HTMLElement, + store: Store, + showDetail: (mrf: string) => void + ) { + super(container, store); + this.showDetail = showDetail; + } + + render(): void { + const state = this.getState(); + // Use hstTags for group name resolution (set_hst points to hst tags) + const nameMap = createNameMap(state.hstTags, state.lang); + const filtered = filterTags(state.tags, { + search: state.search, + group: state.group, + library: state.library, + libraryMembers: state.libraryMembers, + lang: state.lang + }); + + // Group by set_hst + const groups = new Map(); + filtered.forEach(tag => { + const group = tag.set_hst || 'sin-grupo'; + if (!groups.has(group)) groups.set(group, []); + groups.get(group)!.push(tag); + }); + + if (groups.size === 0) { + this.container.innerHTML = '
Sin resultados
'; + return; + } + + this.container.innerHTML = Array.from(groups.entries()).map(([groupMrf, tags]) => { + const isExpanded = this.expanded.has(groupMrf); + const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap); + return ` +
+
+ ${isExpanded ? '−' : '+'} + ${groupName} + ${tags.length} +
+
+ ${tags.map(tag => { + const img = getImg(tag); + const name = getName(tag, state.lang); + const isSelected = state.selected.has(tag.mrf); + return ` +
+ ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 1) || 'T'}
` + } + ${name} +
+ `; + }).join('')} +
+
+ `; + }).join(''); + + this.bindEvents(); + } + + private bindEvents(): void { + const state = this.getState(); + + delegateEvent(this.container, '.tree-header', 'click', (_, target) => { + const group = target.dataset.group; + if (!group) return; + + if (this.expanded.has(group)) { + this.expanded.delete(group); + } else { + this.expanded.add(group); + } + this.render(); + }); + + delegateEvent(this.container, '.tree-item', 'click', (_, target) => { + const mrf = target.dataset.mrf; + if (!mrf) return; + + if (state.selectionMode) { + const newSelected = new Set(state.selected); + if (newSelected.has(mrf)) { + newSelected.delete(mrf); + } else { + newSelected.add(mrf); + } + this.setState({ selected: newSelected }); + } else { + this.showDetail(mrf); + } + }); + } +} diff --git a/deck-frontend/backups/20260113_212146/src/views/View.ts b/deck-frontend/backups/20260113_212146/src/views/View.ts new file mode 100644 index 0000000..7697010 --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/views/View.ts @@ -0,0 +1,33 @@ +import type { Store } from '@/state/store.ts'; +import type { AppState } from '@/types/index.ts'; + +export abstract class View { + protected container: HTMLElement; + protected store: Store; + protected unsubscribe?: () => void; + + constructor(container: HTMLElement, store: Store) { + this.container = container; + this.store = store; + } + + abstract render(): void; + + mount(): void { + this.unsubscribe = this.store.subscribe(() => this.render()); + this.render(); + } + + unmount(): void { + this.unsubscribe?.(); + this.container.innerHTML = ''; + } + + protected getState(): AppState { + return this.store.getState(); + } + + protected setState(partial: Partial): void { + this.store.setState(partial); + } +} diff --git a/deck-frontend/backups/20260113_212146/src/views/index.ts b/deck-frontend/backups/20260113_212146/src/views/index.ts new file mode 100644 index 0000000..46261be --- /dev/null +++ b/deck-frontend/backups/20260113_212146/src/views/index.ts @@ -0,0 +1,5 @@ +export { View } from './View.ts'; +export { GridView } from './GridView/GridView.ts'; +export { TreeView } from './TreeView/TreeView.ts'; +export { GraphView } from './GraphView/GraphView.ts'; +export { DetailPanel } from './DetailPanel/DetailPanel.ts'; diff --git a/deck-frontend/backups/20260113_221902/index.html b/deck-frontend/backups/20260113_221902/index.html new file mode 100644 index 0000000..8881f20 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/index.html @@ -0,0 +1,129 @@ + + + + DECK + + + + +
+ +
+
+ + + +
+
+ +
+ + + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+ + +
+
+ + + +
+
+ + + +
+
+
+ + +
+ + +
+ +
+
ALL
+
+ + +
+
+
Cargando...
+
+
+ + +
+
+ + + +
+ + + + diff --git a/deck-frontend/backups/20260113_221902/src/api/client.ts b/deck-frontend/backups/20260113_221902/src/api/client.ts new file mode 100644 index 0000000..03f0f69 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/api/client.ts @@ -0,0 +1,43 @@ +import { API_BASE } from '@/config/index.ts'; + +interface FetchOptions { + method?: 'GET' | 'POST'; + body?: Record; + schema?: string; // PostgREST Accept-Profile header +} + +export async function apiClient( + endpoint: string, + options: FetchOptions = {} +): Promise { + const { method = 'GET', body, schema } = options; + + const headers: Record = {}; + if (body) headers['Content-Type'] = 'application/json'; + if (schema) headers['Accept-Profile'] = schema; + + const config: RequestInit = { + method, + headers: Object.keys(headers).length > 0 ? headers : undefined, + body: body ? JSON.stringify(body) : undefined, + }; + + const response = await fetch(`${API_BASE}${endpoint}`, config); + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + return response.json(); +} + +export async function apiClientSafe( + endpoint: string, + options: FetchOptions = {}, + fallback: T +): Promise { + try { + return await apiClient(endpoint, options); + } catch { + console.error(`API call failed: ${endpoint}`); + return fallback; + } +} diff --git a/deck-frontend/backups/20260113_221902/src/api/graph.ts b/deck-frontend/backups/20260113_221902/src/api/graph.ts new file mode 100644 index 0000000..22b0b66 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/api/graph.ts @@ -0,0 +1,8 @@ +import { apiClientSafe } from './client.ts'; +import type { GraphEdge, TreeEdge } from '@/types/index.ts'; + +export const fetchGraphEdges = (): Promise => + apiClientSafe('/graph_hst', {}, []); + +export const fetchTreeEdges = (): Promise => + apiClientSafe('/tree_hst', {}, []); diff --git a/deck-frontend/backups/20260113_221902/src/api/groups.ts b/deck-frontend/backups/20260113_221902/src/api/groups.ts new file mode 100644 index 0000000..af08297 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/api/groups.ts @@ -0,0 +1,5 @@ +import { apiClientSafe } from './client.ts'; +import type { Group } from '@/types/index.ts'; + +export const fetchGroups = (): Promise => + apiClientSafe('/api_groups', {}, []); diff --git a/deck-frontend/backups/20260113_221902/src/api/index.ts b/deck-frontend/backups/20260113_221902/src/api/index.ts new file mode 100644 index 0000000..8d6cd78 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/api/index.ts @@ -0,0 +1,5 @@ +export { apiClient, apiClientSafe } from './client.ts'; +export { fetchTags, fetchHstTags, fetchChildren, fetchRelated } from './tags.ts'; +export { fetchGroups } from './groups.ts'; +export { fetchLibraries, fetchLibraryMembers } from './libraries.ts'; +export { fetchGraphEdges, fetchTreeEdges } from './graph.ts'; diff --git a/deck-frontend/backups/20260113_221902/src/api/libraries.ts b/deck-frontend/backups/20260113_221902/src/api/libraries.ts new file mode 100644 index 0000000..44f5f3a --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/api/libraries.ts @@ -0,0 +1,26 @@ +import { apiClientSafe } from './client.ts'; +import type { Library, BaseType } from '@/types/index.ts'; + +// Base types that have library tables (public schema taxonomy tables) +const LIBRARY_BASES = new Set(['hst', 'flg', 'itm', 'loc', 'ply']); + +export const fetchLibraries = (base: BaseType): Promise => { + // Only public schema taxonomy tables have libraries + if (!LIBRARY_BASES.has(base)) { + return Promise.resolve([]); + } + // Use base-specific view: api_library_list_hst, api_library_list_flg, etc. + return apiClientSafe(`/api_library_list_${base}`, {}, []); +}; + +export const fetchLibraryMembers = async (mrf: string, base: BaseType): Promise => { + if (!LIBRARY_BASES.has(base)) { + return []; + } + const data = await apiClientSafe>( + `/library_${base}?mrf_library=eq.${mrf}`, + {}, + [] + ); + return data.map(d => d.mrf_tag); +}; diff --git a/deck-frontend/backups/20260113_221902/src/api/tags.ts b/deck-frontend/backups/20260113_221902/src/api/tags.ts new file mode 100644 index 0000000..cba9f0b --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/api/tags.ts @@ -0,0 +1,65 @@ +import { apiClientSafe } from './client.ts'; +import type { Tag, ChildTag, RelatedTag, BaseType } from '@/types/index.ts'; + +// Schema mapping by base type +// - public (default): hst, flg, itm, loc, ply +// - secretaria_clara: atc, mst, bck +// - production_alfred: mth +// - mail_manager: mail (table: clara_registros) +// - context_manager: chat (table: messages) + +interface SchemaTableConfig { + schema: string | null; + table: string; +} + +const getSchemaAndTable = (base: BaseType): SchemaTableConfig => { + switch (base) { + // secretaria_clara schema + case 'atc': + case 'mst': + case 'bck': + return { schema: 'secretaria_clara', table: base }; + + // production_alfred schema + case 'mth': + return { schema: 'production_alfred', table: base }; + + // mail_manager schema + case 'mail': + return { schema: 'mail_manager', table: 'clara_registros' }; + + // context_manager schema + case 'chat': + return { schema: 'context_manager', table: 'messages' }; + + // public schema (default) - hst, flg, itm, loc, ply + default: + return { schema: null, table: base }; + } +}; + +export const fetchTags = (base: BaseType): Promise => { + const { schema, table } = getSchemaAndTable(base); + return apiClientSafe( + `/${table}?order=ref.asc`, + schema ? { schema } : {}, + [] + ); +}; + +// Fetch HST tags for group name resolution (set_hst points to hst tags) +export const fetchHstTags = (): Promise => + apiClientSafe('/hst?select=mrf,ref,alias,name_es,name_en,name_ch', {}, []); + +export const fetchChildren = (mrf: string): Promise => + apiClientSafe('/rpc/api_children', { + method: 'POST', + body: { parent_mrf: mrf } + }, []); + +export const fetchRelated = (mrf: string): Promise => + apiClientSafe('/rpc/api_related', { + method: 'POST', + body: { tag_mrf: mrf } + }, []); diff --git a/deck-frontend/backups/20260113_221902/src/components/Card/Card.ts b/deck-frontend/backups/20260113_221902/src/components/Card/Card.ts new file mode 100644 index 0000000..318db1d --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/components/Card/Card.ts @@ -0,0 +1,47 @@ +import { Component } from '../Component.ts'; +import type { Tag, LangType } from '@/types/index.ts'; +import { getName, getImg } from '@/utils/index.ts'; + +export interface CardProps { + tag: Tag; + lang: LangType; + selected: boolean; + selectionMode: boolean; + onClick: (mrf: string) => void; + onSelect: (mrf: string) => void; +} + +export class Card extends Component { + protected template(): string { + const { tag, lang, selected, selectionMode } = this.props; + const img = getImg(tag); + const name = getName(tag, lang); + + return ` +
+ ${selectionMode ? ` + + ` : ''} + ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 2) || 'T'}
` + } +
${name}
+
+ `; + } + + protected bindEvents(): void { + const { onClick, onSelect, selectionMode } = this.props; + const mrf = this.props.tag.mrf; + + this.element.addEventListener('click', (e) => { + if (selectionMode) { + e.preventDefault(); + onSelect(mrf); + } else { + onClick(mrf); + } + }); + } +} diff --git a/deck-frontend/backups/20260113_221902/src/components/Component.ts b/deck-frontend/backups/20260113_221902/src/components/Component.ts new file mode 100644 index 0000000..f68cf52 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/components/Component.ts @@ -0,0 +1,42 @@ +export abstract class Component

{ + protected element: HTMLElement; + protected props: P; + + constructor(props: P) { + this.props = props; + this.element = this.createElement(); + this.bindEvents(); + } + + protected abstract template(): string; + + protected createElement(): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.innerHTML = this.template().trim(); + return wrapper.firstElementChild as HTMLElement; + } + + protected bindEvents(): void { + // Override in subclasses + } + + public mount(container: HTMLElement): void { + container.appendChild(this.element); + } + + public unmount(): void { + this.element.remove(); + } + + public update(props: Partial

): void { + this.props = { ...this.props, ...props }; + const newElement = this.createElement(); + this.element.replaceWith(newElement); + this.element = newElement; + this.bindEvents(); + } + + public getElement(): HTMLElement { + return this.element; + } +} diff --git a/deck-frontend/backups/20260113_221902/src/components/Modal/Modal.ts b/deck-frontend/backups/20260113_221902/src/components/Modal/Modal.ts new file mode 100644 index 0000000..d3b0bb8 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/components/Modal/Modal.ts @@ -0,0 +1,46 @@ +import { Component } from '../Component.ts'; + +export interface ModalProps { + title: string; + content: string; + isOpen: boolean; + onClose: () => void; +} + +export class Modal extends Component { + protected template(): string { + const { title, content, isOpen } = this.props; + return ` +

+ `; + } + + protected bindEvents(): void { + const closeBtn = this.element.querySelector('.modal-close'); + closeBtn?.addEventListener('click', this.props.onClose); + + this.element.addEventListener('click', (e) => { + if (e.target === this.element) { + this.props.onClose(); + } + }); + } + + public open(): void { + this.element.classList.add('open'); + } + + public close(): void { + this.element.classList.remove('open'); + } +} diff --git a/deck-frontend/backups/20260113_221902/src/components/TagChip/TagChip.ts b/deck-frontend/backups/20260113_221902/src/components/TagChip/TagChip.ts new file mode 100644 index 0000000..271bc5e --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/components/TagChip/TagChip.ts @@ -0,0 +1,25 @@ +import { Component } from '../Component.ts'; + +export interface TagChipProps { + mrf: string; + label: string; + title?: string; + onClick: (mrf: string) => void; +} + +export class TagChip extends Component { + protected template(): string { + const { mrf, label, title } = this.props; + return ` + + ${label} + + `; + } + + protected bindEvents(): void { + this.element.addEventListener('click', () => { + this.props.onClick(this.props.mrf); + }); + } +} diff --git a/deck-frontend/backups/20260113_221902/src/components/index.ts b/deck-frontend/backups/20260113_221902/src/components/index.ts new file mode 100644 index 0000000..df484ec --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/components/index.ts @@ -0,0 +1,4 @@ +export { Component } from './Component.ts'; +export { Card, type CardProps } from './Card/Card.ts'; +export { TagChip, type TagChipProps } from './TagChip/TagChip.ts'; +export { Modal, type ModalProps } from './Modal/Modal.ts'; diff --git a/deck-frontend/backups/20260113_221902/src/config/api.ts b/deck-frontend/backups/20260113_221902/src/config/api.ts new file mode 100644 index 0000000..7030377 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/config/api.ts @@ -0,0 +1 @@ +export const API_BASE = '/api'; diff --git a/deck-frontend/backups/20260113_221902/src/config/categories.ts b/deck-frontend/backups/20260113_221902/src/config/categories.ts new file mode 100644 index 0000000..a19de89 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/config/categories.ts @@ -0,0 +1,15 @@ +import type { CategoryKey } from '@/types/index.ts'; + +export interface CategoryConfig { + name: string; + color: string; +} + +export const CATS: Record = { + hst: { name: 'Hashtags', color: '#7c8aff' }, + spe: { name: 'Specs', color: '#FF9800' }, + vue: { name: 'Values', color: '#00BCD4' }, + vsn: { name: 'Visions', color: '#E91E63' }, + msn: { name: 'Missions', color: '#9C27B0' }, + flg: { name: 'Flags', color: '#4CAF50' } +}; diff --git a/deck-frontend/backups/20260113_221902/src/config/edges.ts b/deck-frontend/backups/20260113_221902/src/config/edges.ts new file mode 100644 index 0000000..ab64dd8 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/config/edges.ts @@ -0,0 +1,14 @@ +import type { EdgeType } from '@/types/index.ts'; + +export const EDGE_COLORS: Record = { + relation: '#8BC34A', + specialization: '#9C27B0', + mirror: '#607D8B', + dependency: '#2196F3', + sequence: '#4CAF50', + composition: '#FF9800', + hierarchy: '#E91E63', + library: '#00BCD4', + contextual: '#FFC107', + association: '#795548' +}; diff --git a/deck-frontend/backups/20260113_221902/src/config/index.ts b/deck-frontend/backups/20260113_221902/src/config/index.ts new file mode 100644 index 0000000..23dfe12 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/config/index.ts @@ -0,0 +1,3 @@ +export { CATS, type CategoryConfig } from './categories.ts'; +export { EDGE_COLORS } from './edges.ts'; +export { API_BASE } from './api.ts'; diff --git a/deck-frontend/backups/20260113_221902/src/main.ts b/deck-frontend/backups/20260113_221902/src/main.ts new file mode 100644 index 0000000..d1b20d9 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/main.ts @@ -0,0 +1,491 @@ +import { store } from '@/state/index.ts'; +import { Router } from '@/router/index.ts'; +import { fetchTags, fetchHstTags, fetchGroups, fetchLibraries, fetchLibraryMembers } from '@/api/index.ts'; +import { GridView, TreeView, GraphView, DetailPanel } from '@/views/index.ts'; +import { $, $$, delegateEvent, toast, createNameMap, resolveGroupName } from '@/utils/index.ts'; +import { CATS, EDGE_COLORS } from '@/config/index.ts'; +import type { BaseType, ViewType, CategoryKey, EdgeType } from '@/types/index.ts'; +import './styles/main.css'; + +class App { + private router: Router; + private currentView: GridView | TreeView | GraphView | null = null; + private detailPanel: DetailPanel | null = null; + + constructor() { + this.router = new Router(store, () => this.init()); + } + + async start(): Promise { + this.router.parseHash(); + await this.init(); + this.bindEvents(); + } + + private async init(): Promise { + const contentArea = $('#content-area'); + const detailPanelEl = $('#detail-panel'); + if (!contentArea || !detailPanelEl) return; + + // Update UI + this.updateBaseButtons(); + this.updateViewTabs(); + + // Show loading + contentArea.innerHTML = '
Cargando...
'; + + // Fetch data + const state = store.getState(); + const [tags, hstTags, groups, libraries] = await Promise.all([ + fetchTags(state.base), + fetchHstTags(), // Always load HST for group name resolution + fetchGroups(), + fetchLibraries(state.base) // Load libraries for current base + ]); + store.setState({ tags, hstTags, groups, libraries }); + + // Render groups + this.renderGroups(); + this.renderLibraries(); + + // Setup detail panel + if (!this.detailPanel) { + this.detailPanel = new DetailPanel(detailPanelEl, store); + } + + // Render view + this.renderView(); + } + + private renderView(): void { + const contentArea = $('#content-area'); + if (!contentArea) return; + + const state = store.getState(); + const showDetail = (mrf: string) => this.detailPanel?.showDetail(mrf); + + // Unmount current view + this.currentView?.unmount(); + + // Clear and set class + contentArea.innerHTML = ''; + contentArea.className = `content-area ${state.view}-view`; + + // Mount new view + switch (state.view) { + case 'grid': + this.currentView = new GridView(contentArea, store, showDetail); + this.currentView.mount(); + break; + case 'tree': + this.currentView = new TreeView(contentArea, store, showDetail); + this.currentView.mount(); + break; + case 'graph': + this.currentView = new GraphView(contentArea, store, showDetail); + (this.currentView as GraphView).mount(); + break; + } + } + + private renderGroups(): void { + const container = $('#groups-bar'); + if (!container) return; + + const state = store.getState(); + // Use hstTags for group name resolution (set_hst points to hst tags) + const nameMap = createNameMap(state.hstTags, state.lang); + + // Count tags per group + const counts = new Map(); + state.tags.forEach(tag => { + const group = tag.set_hst || 'sin-grupo'; + counts.set(group, (counts.get(group) || 0) + 1); + }); + + // Sort by count and take top 20 + const sorted = Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20); + + container.innerHTML = ` + + ${sorted.map(([groupMrf, count]) => { + const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap); + return ` + + `; + }).join('')} + `; + + delegateEvent(container, '.group-btn', 'click', (_, target) => { + const group = target.dataset.group || 'all'; + store.setState({ group }); + this.renderGroups(); + this.renderView(); + }); + } + + private renderLibraries(): void { + const container = $('#left-panel'); + if (!container) return; + + const state = store.getState(); + + // Show graph options when in graph view + if (state.view === 'graph') { + container.classList.add('graph-mode'); + this.renderGraphOptions(container); + return; + } + + container.classList.remove('graph-mode'); + container.innerHTML = ` +
+ ALL +
+ ${state.libraries.map(lib => { + const icon = lib.img_thumb_url || lib.icon_url || ''; + const name = lib.name || lib.name_es || lib.alias || lib.ref || lib.mrf.slice(0, 6); + return ` +
+ ${icon ? `` : ''} + ${name.slice(0, 8)} +
+ `; + }).join('')} + `; + + delegateEvent(container, '.lib-icon', 'click', async (_, target) => { + const library = target.dataset.lib || 'all'; + const currentBase = store.getState().base; + + if (library === 'all') { + store.setState({ library: 'all', libraryMembers: new Set() }); + } else { + const members = await fetchLibraryMembers(library, currentBase); + store.setState({ library, libraryMembers: new Set(members) }); + } + + this.renderLibraries(); + this.renderView(); + }); + } + + private renderGraphOptions(container: HTMLElement): void { + const state = store.getState(); + const { graphFilters, graphSettings, tags, graphEdges } = state; + + // Count nodes and edges + const nodeCount = tags.length; + const edgeCount = graphEdges.length; + + container.innerHTML = ` +
+ +
+
+ Nodos + ${nodeCount} +
+
+ Edges + ${edgeCount} +
+
+ + +
+
Categorias
+ ${Object.entries(CATS).map(([key, config]) => ` + + `).join('')} +
+ + +
+
Relaciones
+ ${Object.entries(EDGE_COLORS).map(([key, color]) => ` + + `).join('')} +
+ + +
+
Visualizacion
+ + +
+
+ Nodo + ${graphSettings.nodeSize}px +
+ +
+
+
+ Distancia + ${graphSettings.linkDist}px +
+ +
+
+
+ `; + + // Bind events + this.bindGraphOptionEvents(container); + } + + private bindGraphOptionEvents(container: HTMLElement): void { + // Category checkboxes + container.querySelectorAll('[data-cat]').forEach(cb => { + cb.addEventListener('change', () => { + const cat = cb.dataset.cat as CategoryKey; + const state = store.getState(); + const newCats = new Set(state.graphFilters.cats); + if (cb.checked) { + newCats.add(cat); + } else { + newCats.delete(cat); + } + store.setState({ + graphFilters: { ...state.graphFilters, cats: newCats } + }); + this.renderView(); + }); + }); + + // Edge checkboxes + container.querySelectorAll('[data-edge]').forEach(cb => { + cb.addEventListener('change', () => { + const edge = cb.dataset.edge as EdgeType; + const state = store.getState(); + const newEdges = new Set(state.graphFilters.edges); + if (cb.checked) { + newEdges.add(edge); + } else { + newEdges.delete(edge); + } + store.setState({ + graphFilters: { ...state.graphFilters, edges: newEdges } + }); + this.renderView(); + }); + }); + + // Show images checkbox + const showImgCb = container.querySelector('#graph-show-img'); + showImgCb?.addEventListener('change', () => { + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, showImg: showImgCb.checked } + }); + this.renderView(); + }); + + // Show labels checkbox + const showLblCb = container.querySelector('#graph-show-lbl'); + showLblCb?.addEventListener('change', () => { + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, showLbl: showLblCb.checked } + }); + this.renderView(); + }); + + // Node size slider + const nodeSizeSlider = container.querySelector('#graph-node-size'); + const nodeSizeVal = container.querySelector('#node-size-val'); + nodeSizeSlider?.addEventListener('input', () => { + const size = parseInt(nodeSizeSlider.value, 10); + if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`; + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, nodeSize: size } + }); + this.renderView(); + }); + + // Link distance slider + const linkDistSlider = container.querySelector('#graph-link-dist'); + const linkDistVal = container.querySelector('#link-dist-val'); + linkDistSlider?.addEventListener('input', () => { + const dist = parseInt(linkDistSlider.value, 10); + if (linkDistVal) linkDistVal.textContent = `${dist}px`; + const state = store.getState(); + store.setState({ + graphSettings: { ...state.graphSettings, linkDist: dist } + }); + this.renderView(); + }); + } + + private updateBaseButtons(): void { + const state = store.getState(); + $$('.base-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.base === state.base); + }); + } + + private updateViewTabs(): void { + const state = store.getState(); + $$('.view-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.view === state.view); + }); + } + + private bindEvents(): void { + // Base buttons + delegateEvent(document.body, '.base-btn', 'click', async (_, target) => { + const base = target.dataset.base as BaseType; + if (!base) return; + + store.setState({ + base, + group: 'all', + library: 'all', + libraryMembers: new Set(), + search: '', + graphEdges: [], + treeEdges: [], + selected: new Set(), + selectionMode: false + }); + + this.router.updateHash(); + await this.init(); + }); + + // View tabs + delegateEvent(document.body, '.view-tab', 'click', (_, target) => { + const view = target.dataset.view as ViewType; + if (!view) return; + + store.setState({ view }); + this.router.updateHash(); + this.detailPanel?.close(); + this.updateViewTabs(); + this.renderLibraries(); // Update left panel (graph options vs libraries) + this.renderView(); + }); + + // Search + const searchInput = $('#search') as HTMLInputElement; + if (searchInput) { + let timeout: number; + searchInput.addEventListener('input', () => { + clearTimeout(timeout); + timeout = window.setTimeout(() => { + store.setState({ search: searchInput.value }); + this.renderView(); + }, 200); + }); + } + + // Language select + const langSelect = $('#lang-select') as HTMLSelectElement; + if (langSelect) { + langSelect.addEventListener('change', () => { + store.setState({ lang: langSelect.value as 'es' | 'en' | 'ch' }); + this.renderView(); + }); + } + + // Selection mode + const selBtn = $('#btn-sel'); + if (selBtn) { + selBtn.addEventListener('click', () => { + const state = store.getState(); + store.setState({ + selectionMode: !state.selectionMode, + selected: state.selectionMode ? new Set() : state.selected + }); + selBtn.classList.toggle('active', !state.selectionMode); + this.updateSelectionCount(); + this.renderView(); + }); + } + + // Get selected + const getBtn = $('#btn-get'); + if (getBtn) { + getBtn.addEventListener('click', () => { + const state = store.getState(); + if (state.selected.size === 0) { + toast('No hay seleccionados'); + return; + } + navigator.clipboard.writeText([...state.selected].join('\n')) + .then(() => toast(`Copiados ${state.selected.size} mrfs`)); + }); + } + + // API modal + const apiBtn = $('#btn-api'); + const apiModal = $('#api-modal'); + if (apiBtn && apiModal) { + apiBtn.addEventListener('click', () => apiModal.classList.add('open')); + apiModal.addEventListener('click', (e) => { + if (e.target === apiModal) apiModal.classList.remove('open'); + }); + const closeBtn = apiModal.querySelector('.modal-close'); + closeBtn?.addEventListener('click', () => apiModal.classList.remove('open')); + } + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.detailPanel?.close(); + $('#api-modal')?.classList.remove('open'); + if (store.getState().selectionMode) { + store.setState({ selectionMode: false, selected: new Set() }); + $('#btn-sel')?.classList.remove('active'); + this.renderView(); + } + } + if (e.key === '/' && (e.target as HTMLElement).tagName !== 'INPUT') { + e.preventDefault(); + ($('#search') as HTMLInputElement)?.focus(); + } + }); + + // Suscribir al store para actualizar contador cuando cambia selected + store.subscribe((state, prevState) => { + if (state.selected !== prevState.selected) { + this.updateSelectionCount(); + } + }); + } + + private updateSelectionCount(): void { + const counter = $('#sel-count'); + if (counter) { + const count = store.getState().selected.size; + counter.textContent = count > 0 ? `(${count})` : ''; + } + } +} + +// Bootstrap +document.addEventListener('DOMContentLoaded', () => { + new App().start(); +}); diff --git a/deck-frontend/backups/20260113_221902/src/modules/configs/index.ts b/deck-frontend/backups/20260113_221902/src/modules/configs/index.ts new file mode 100644 index 0000000..0396879 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/modules/configs/index.ts @@ -0,0 +1,368 @@ +/** + * Module Configurations - Registro central de todos los módulos + */ + +import type { BaseConfig, ModuleCategory } from '../registry.ts'; +import type { BaseType, ViewType } from '@/types/index.ts'; + +// Configuración de todos los módulos +export const MODULE_CONFIGS: Record = { + // ═══════════════════════════════════════════════════════════════ + // TAXONOMÍA (public schema) + // ═══════════════════════════════════════════════════════════════ + hst: { + id: 'hst', + name: 'Hashtags Semánticos', + shortName: 'HST', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: true }, + defaultView: 'grid', + api: { + schema: null, + table: 'hst', + hasLibraries: true, + hasGroups: true, + hasGraph: true, + hasTree: true + }, + enabled: true + }, + + flg: { + id: 'flg', + name: 'Flags', + shortName: 'FLG', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'flg', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + itm: { + id: 'itm', + name: 'Items', + shortName: 'ITM', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'itm', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + loc: { + id: 'loc', + name: 'Locations', + shortName: 'LOC', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'loc', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + ply: { + id: 'ply', + name: 'Players', + shortName: 'PLY', + category: 'taxonomy', + renderType: 'standard', + views: { grid: true, tree: true, graph: false }, + defaultView: 'grid', + api: { + schema: null, + table: 'ply', + hasLibraries: true, + hasGroups: true, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + // ═══════════════════════════════════════════════════════════════ + // MAESTROS (secretaria_clara schema) + // ═══════════════════════════════════════════════════════════════ + mst: { + id: 'mst', + name: 'Masters', + shortName: 'MST', + category: 'masters', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'secretaria_clara', + table: 'mst', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + bck: { + id: 'bck', + name: 'Backups', + shortName: 'BCK', + category: 'masters', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'secretaria_clara', + table: 'bck', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + // ═══════════════════════════════════════════════════════════════ + // REGISTRO (secretaria_clara / production_alfred) + // ═══════════════════════════════════════════════════════════════ + atc: { + id: 'atc', + name: 'Attachments', + shortName: 'ATC', + category: 'registry', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'secretaria_clara', + table: 'atc', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + mth: { + id: 'mth', + name: 'Methods', + shortName: 'MTH', + category: 'registry', + renderType: 'standard', + views: { grid: true, tree: false, graph: false }, + defaultView: 'grid', + api: { + schema: 'production_alfred', + table: 'mth', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + enabled: true + }, + + // ═══════════════════════════════════════════════════════════════ + // COMUNICACIÓN (mail_manager / context_manager) + // Interfaz de chat con IA + // ═══════════════════════════════════════════════════════════════ + mail: { + id: 'mail', + name: 'Mail Assistant', + shortName: 'MAIL', + category: 'communication', + renderType: 'chat', + views: { custom: 'ChatView' }, + defaultView: 'custom', + api: { + schema: 'mail_manager', + table: 'clara_registros', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/MailModule.ts'), + enabled: false // Próximamente + }, + + chat: { + id: 'chat', + name: 'Context Manager', + shortName: 'CHAT', + category: 'communication', + renderType: 'chat', + views: { custom: 'ChatView' }, + defaultView: 'custom', + api: { + schema: 'context_manager', + table: 'messages', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/ContextModule.ts'), + enabled: false // Próximamente + }, + + // ═══════════════════════════════════════════════════════════════ + // SERVICIOS (interfaces custom) + // ═══════════════════════════════════════════════════════════════ + key: { + id: 'key', + name: 'Keys', + shortName: 'KEY', + category: 'services', + renderType: 'custom', + views: { custom: 'KeyView' }, + defaultView: 'custom', + api: { + schema: null, + table: 'key', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/KeyModule.ts'), + enabled: false // Próximamente + }, + + mindlink: { + id: 'mindlink', + name: 'MindLink', + shortName: 'MIND', + category: 'services', + renderType: 'custom', + views: { custom: 'MindlinkView' }, + defaultView: 'custom', + api: { + schema: null, + table: 'mindlink', + hasLibraries: false, + hasGroups: false, + hasGraph: false, + hasTree: false + }, + customModule: () => import('../custom/MindlinkModule.ts'), + enabled: false // Próximamente + } +}; + +// ═══════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════ + +/** + * Obtener configuración de un módulo + */ +export const getModuleConfig = (base: BaseType): BaseConfig => { + const config = MODULE_CONFIGS[base]; + if (!config) { + throw new Error(`Module config not found for base: ${base}`); + } + return config; +}; + +/** + * Agrupar módulos por categoría para UI + */ +export const getModulesByCategory = (): Record => { + const result: Record = { + taxonomy: [], + masters: [], + registry: [], + communication: [], + services: [] + }; + + Object.values(MODULE_CONFIGS).forEach(config => { + result[config.category].push(config); + }); + + return result; +}; + +/** + * Obtener solo módulos habilitados + */ +export const getEnabledModules = (): BaseConfig[] => { + return Object.values(MODULE_CONFIGS).filter(c => c.enabled !== false); +}; + +/** + * Verificar si un módulo está habilitado + */ +export const isModuleEnabled = (base: BaseType): boolean => { + return MODULE_CONFIGS[base]?.enabled !== false; +}; + +/** + * Obtener schema y tabla para API (compatibilidad con código existente) + */ +export const getSchemaAndTable = (base: BaseType): { schema: string | null; table: string } => { + const config = MODULE_CONFIGS[base]; + if (!config) { + return { schema: null, table: base }; + } + return { + schema: config.api.schema, + table: config.api.table + }; +}; + +/** + * Verificar si una base soporta bibliotecas + */ +export const supportsLibraries = (base: BaseType): boolean => { + return MODULE_CONFIGS[base]?.api.hasLibraries ?? false; +}; + +/** + * Verificar si una base soporta grupos + */ +export const supportsGroups = (base: BaseType): boolean => { + return MODULE_CONFIGS[base]?.api.hasGroups ?? false; +}; + +/** + * Verificar si una vista está soportada por una base + */ +export const supportsView = (base: BaseType, view: ViewType): boolean => { + const config = MODULE_CONFIGS[base]; + if (!config) return false; + return !!config.views[view]; +}; + +/** + * Obtener vista por defecto de una base + */ +export const getDefaultView = (base: BaseType): ViewType | 'custom' => { + return MODULE_CONFIGS[base]?.defaultView ?? 'grid'; +}; diff --git a/deck-frontend/backups/20260113_221902/src/modules/custom/ContextModule.ts b/deck-frontend/backups/20260113_221902/src/modules/custom/ContextModule.ts new file mode 100644 index 0000000..da0a0cf --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/modules/custom/ContextModule.ts @@ -0,0 +1,31 @@ +/** + * ContextModule - Módulo de chat con IA genérico + * + * TODO: Implementar interfaz de chat estilo ChatGPT/Claude + * para interactuar con diferentes modelos de IA controlando el contexto. + */ + +import { BaseModule } from '../registry.ts'; + +export class ContextModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
💬
+
Context Manager
+
Chat con IA - Próximamente
+
+ `; + } +} + +export default ContextModule; diff --git a/deck-frontend/backups/20260113_221902/src/modules/custom/KeyModule.ts b/deck-frontend/backups/20260113_221902/src/modules/custom/KeyModule.ts new file mode 100644 index 0000000..b5e20d2 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/modules/custom/KeyModule.ts @@ -0,0 +1,30 @@ +/** + * KeyModule - Módulo de gestión de claves + * + * TODO: Implementar interfaz para gestionar claves y credenciales. + */ + +import { BaseModule } from '../registry.ts'; + +export class KeyModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
🔑
+
Keys
+
Gestión de claves - Próximamente
+
+ `; + } +} + +export default KeyModule; diff --git a/deck-frontend/backups/20260113_221902/src/modules/custom/MailModule.ts b/deck-frontend/backups/20260113_221902/src/modules/custom/MailModule.ts new file mode 100644 index 0000000..1f5d816 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/modules/custom/MailModule.ts @@ -0,0 +1,31 @@ +/** + * MailModule - Módulo de chat con IA para mail + * + * TODO: Implementar interfaz de chat estilo ChatGPT/Claude + * donde el contexto es el correo electrónico. + */ + +import { BaseModule } from '../registry.ts'; + +export class MailModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
📧
+
Mail Assistant
+
Interfaz de chat con IA - Próximamente
+
+ `; + } +} + +export default MailModule; diff --git a/deck-frontend/backups/20260113_221902/src/modules/custom/MindlinkModule.ts b/deck-frontend/backups/20260113_221902/src/modules/custom/MindlinkModule.ts new file mode 100644 index 0000000..64073d5 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/modules/custom/MindlinkModule.ts @@ -0,0 +1,31 @@ +/** + * MindlinkModule - Módulo de gestión de hipervínculos + * + * TODO: Implementar interfaz de árboles visuales con imágenes + * y vínculos a archivos/recursos. + */ + +import { BaseModule } from '../registry.ts'; + +export class MindlinkModule extends BaseModule { + async mount(): Promise { + this.render(); + this.mounted = true; + } + + unmount(): void { + this.mounted = false; + } + + render(): void { + this.ctx.container.innerHTML = ` +
+
🔗
+
MindLink
+
Gestión de hipervínculos - Próximamente
+
+ `; + } +} + +export default MindlinkModule; diff --git a/deck-frontend/backups/20260113_221902/src/modules/custom/index.ts b/deck-frontend/backups/20260113_221902/src/modules/custom/index.ts new file mode 100644 index 0000000..3595038 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/modules/custom/index.ts @@ -0,0 +1,4 @@ +export { MailModule } from './MailModule.ts'; +export { ContextModule } from './ContextModule.ts'; +export { KeyModule } from './KeyModule.ts'; +export { MindlinkModule } from './MindlinkModule.ts'; diff --git a/deck-frontend/backups/20260113_221902/src/modules/index.ts b/deck-frontend/backups/20260113_221902/src/modules/index.ts new file mode 100644 index 0000000..71eb421 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/modules/index.ts @@ -0,0 +1,35 @@ +/** + * Modules - Sistema modular para DECK Frontend + */ + +// Registry (tipos e interfaces) +export { + type ModuleRenderType, + type ModuleCategory, + type ModuleViews, + type ModuleApiConfig, + type BaseConfig, + type ModuleState, + type ModuleContext, + BaseModule +} from './registry.ts'; + +// Configs (registro de módulos) +export { + MODULE_CONFIGS, + getModuleConfig, + getModulesByCategory, + getEnabledModules, + isModuleEnabled, + getSchemaAndTable, + supportsLibraries, + supportsGroups, + supportsView, + getDefaultView +} from './configs/index.ts'; + +// Loader +export { ModuleLoader, type LoaderTargets } from './loader.ts'; + +// Standard module +export { StandardModule } from './standard/index.ts'; diff --git a/deck-frontend/backups/20260113_221902/src/modules/loader.ts b/deck-frontend/backups/20260113_221902/src/modules/loader.ts new file mode 100644 index 0000000..3ced06f --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/modules/loader.ts @@ -0,0 +1,174 @@ +/** + * ModuleLoader - Carga dinámica de módulos + * + * Responsabilidades: + * - Cargar el módulo correcto según la base + * - Manejar cache de módulos + * - Gestionar lifecycle (mount/unmount) + */ + +import { BaseModule, type ModuleContext } from './registry.ts'; +import { getModuleConfig, isModuleEnabled } from './configs/index.ts'; +import { StandardModule } from './standard/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, BaseType } from '@/types/index.ts'; + +export interface LoaderTargets { + container: HTMLElement; + leftPanel: HTMLElement; + groupsBar: HTMLElement; + showDetail: (mrf: string) => void; +} + +export class ModuleLoader { + private store: Store; + private currentModule: BaseModule | null = null; + private currentBase: BaseType | null = null; + + constructor(store: Store) { + this.store = store; + } + + /** + * Cargar módulo para una base + */ + async load(base: BaseType, targets: LoaderTargets): Promise { + const config = getModuleConfig(base); + + // Verificar si el módulo está habilitado + if (!isModuleEnabled(base)) { + this.showDisabledMessage(targets.container, config.name); + targets.leftPanel.innerHTML = ''; + targets.groupsBar.innerHTML = ''; + return; + } + + // Si ya está cargado el mismo módulo, solo re-renderizar + if (this.currentBase === base && this.currentModule) { + this.currentModule.render(); + return; + } + + // Unmount módulo actual + this.currentModule?.unmount(); + this.currentModule = null; + this.currentBase = null; + + // Show loading + targets.container.innerHTML = '
Cargando...
'; + + // Crear contexto + const ctx: ModuleContext = { + container: targets.container, + leftPanel: targets.leftPanel, + groupsBar: targets.groupsBar, + store: this.store, + config, + showDetail: targets.showDetail + }; + + // Crear módulo según tipo + let module: BaseModule; + + switch (config.renderType) { + case 'standard': + module = new StandardModule(ctx); + break; + + case 'chat': + case 'custom': + // Carga dinámica de módulos custom + if (config.customModule) { + try { + const { default: CustomModule } = await config.customModule(); + module = new CustomModule(ctx); + } catch (error) { + console.error(`Failed to load custom module for ${base}:`, error); + this.showErrorMessage(targets.container, `Error cargando módulo ${config.name}`); + return; + } + } else { + this.showDisabledMessage(targets.container, config.name); + return; + } + break; + + default: + console.error(`Unknown render type: ${config.renderType}`); + this.showErrorMessage(targets.container, 'Tipo de módulo desconocido'); + return; + } + + // Mount módulo + try { + await module.mount(); + this.currentModule = module; + this.currentBase = base; + } catch (error) { + console.error(`Failed to mount module for ${base}:`, error); + this.showErrorMessage(targets.container, `Error inicializando ${config.name}`); + } + } + + /** + * Re-renderizar módulo actual (ej: cuando cambia la vista) + */ + rerender(): void { + if (this.currentModule) { + this.currentModule.render(); + } + } + + /** + * Re-renderizar sidebar del módulo actual + */ + rerenderSidebar(): void { + if (this.currentModule) { + this.currentModule.renderSidebar(); + } + } + + /** + * Obtener módulo actual + */ + getCurrentModule(): BaseModule | null { + return this.currentModule; + } + + /** + * Obtener base actual + */ + getCurrentBase(): BaseType | null { + return this.currentBase; + } + + /** + * Unmount módulo actual + */ + unmount(): void { + this.currentModule?.unmount(); + this.currentModule = null; + this.currentBase = null; + } + + private showDisabledMessage(container: HTMLElement, moduleName: string): void { + container.innerHTML = ` +
+
🚧
+
${moduleName}
+
Próximamente
+
+ `; + } + + private showErrorMessage(container: HTMLElement, message: string): void { + container.innerHTML = ` +
+
⚠️
+
${message}
+
+ `; + } +} + +export default ModuleLoader; diff --git a/deck-frontend/backups/20260113_221902/src/modules/registry.ts b/deck-frontend/backups/20260113_221902/src/modules/registry.ts new file mode 100644 index 0000000..7940f0c --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/modules/registry.ts @@ -0,0 +1,170 @@ +/** + * Module Registry - Tipos e interfaces para el sistema modular + */ + +import type { Store } from '@/state/store.ts'; +import type { AppState, ViewType, BaseType } from '@/types/index.ts'; + +// Tipos de renderizado de módulos +export type ModuleRenderType = + | 'standard' // Grid/Tree/Graph normal (taxonomía, atc) + | 'chat' // Interfaz de chat con IA (mail, context) + | 'custom'; // Interfaz completamente custom (key, mindlink) + +// Categorías de módulos para agrupar en UI +export type ModuleCategory = + | 'taxonomy' // hst, flg, itm, loc, ply + | 'masters' // mst, bck + | 'registry' // atc, mth + | 'communication' // mail, chat + | 'services'; // key, mindlink + +// Configuración de vistas soportadas por módulo +export interface ModuleViews { + grid?: boolean; + tree?: boolean; + graph?: boolean; + custom?: string; // Nombre del componente custom a usar +} + +// Configuración de API por módulo +export interface ModuleApiConfig { + schema: string | null; // PostgREST schema (null = public) + table: string; // Tabla principal + hasLibraries?: boolean; // ¿Soporta bibliotecas? + hasGroups?: boolean; // ¿Soporta grupos (set_hst)? + hasGraph?: boolean; // ¿Tiene datos de grafo? + hasTree?: boolean; // ¿Tiene datos de árbol? +} + +// Configuración completa de un módulo +export interface BaseConfig { + id: BaseType; + name: string; // Nombre completo + shortName: string; // Para botón (3-4 chars) + category: ModuleCategory; + renderType: ModuleRenderType; + + // Vistas soportadas + views: ModuleViews; + defaultView: ViewType | 'custom'; + + // API + api: ModuleApiConfig; + + // Para módulos custom (lazy loading) + customModule?: () => Promise<{ default: new (ctx: ModuleContext) => BaseModule }>; + + // Estado inicial específico del módulo + initialState?: Partial; + + // Módulo habilitado (false = mostrar "Próximamente") + enabled?: boolean; +} + +// Estado específico de un módulo +export interface ModuleState { + loading: boolean; + error: string | null; + data: unknown; +} + +// Contexto pasado a cada módulo +export interface ModuleContext { + container: HTMLElement; + leftPanel: HTMLElement; + groupsBar: HTMLElement; + store: Store; + config: BaseConfig; + showDetail: (mrf: string) => void; +} + +// Clase base abstracta para módulos +export abstract class BaseModule { + protected ctx: ModuleContext; + protected mounted = false; + protected unsubscribe: (() => void) | null = null; + + constructor(ctx: ModuleContext) { + this.ctx = ctx; + } + + // Lifecycle + abstract mount(): Promise; + abstract unmount(): void; + abstract render(): void; + + // Override para carga de datos específica + async loadData(): Promise { + // Default: no hace nada, subclases implementan + } + + // Override para contenido del sidebar (libraries/options) + renderSidebar(): void { + // Default: vacío + this.ctx.leftPanel.innerHTML = ''; + } + + // Override para barra de grupos + renderGroupsBar(): void { + // Default: vacío + this.ctx.groupsBar.innerHTML = ''; + } + + // Helpers + protected getState(): Readonly { + return this.ctx.store.getState(); + } + + protected setState(partial: Partial): void { + this.ctx.store.setState(partial); + } + + protected getConfig(): BaseConfig { + return this.ctx.config; + } + + protected subscribe(listener: (state: AppState) => void): void { + this.unsubscribe = this.ctx.store.subscribe(listener); + } + + // Verificar si una vista está soportada + protected isViewSupported(view: ViewType): boolean { + return !!this.ctx.config.views[view]; + } +} + +// Helper para obtener config de módulo +export const getModuleConfig = (configs: Record, base: BaseType): BaseConfig => { + const config = configs[base]; + if (!config) { + throw new Error(`Module config not found for base: ${base}`); + } + return config; +}; + +// Helper para agrupar módulos por categoría +export const getModulesByCategory = ( + configs: Record +): Record => { + const result: Record = { + taxonomy: [], + masters: [], + registry: [], + communication: [], + services: [] + }; + + Object.values(configs).forEach(config => { + result[config.category].push(config); + }); + + return result; +}; + +// Helper para obtener módulos habilitados +export const getEnabledModules = ( + configs: Record +): BaseConfig[] => { + return Object.values(configs).filter(c => c.enabled !== false); +}; diff --git a/deck-frontend/backups/20260113_221902/src/modules/standard/StandardModule.ts b/deck-frontend/backups/20260113_221902/src/modules/standard/StandardModule.ts new file mode 100644 index 0000000..24c5d6e --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/modules/standard/StandardModule.ts @@ -0,0 +1,323 @@ +/** + * StandardModule - Módulo estándar para bases con vistas Grid/Tree/Graph + * + * Usado por: taxonomía (hst, flg, itm, loc, ply), maestros (mst, bck), + * registro (atc, mth) + */ + +import { BaseModule } from '../registry.ts'; +import { GridView, TreeView, GraphView } from '@/views/index.ts'; +import { fetchTags, fetchHstTags, fetchGroups, fetchLibraries, fetchLibraryMembers } from '@/api/index.ts'; +import { createNameMap, resolveGroupName, delegateEvent } from '@/utils/index.ts'; +import type { ViewType } from '@/types/index.ts'; + +export class StandardModule extends BaseModule { + private currentView: GridView | TreeView | GraphView | null = null; + + async mount(): Promise { + // Show loading + this.ctx.container.innerHTML = '
Cargando...
'; + + // Load data + await this.loadData(); + + // Render sidebar and groups + this.renderSidebar(); + this.renderGroupsBar(); + + // Render main view + this.render(); + + this.mounted = true; + } + + unmount(): void { + this.currentView?.unmount(); + this.currentView = null; + this.unsubscribe?.(); + this.unsubscribe = null; + this.mounted = false; + } + + async loadData(): Promise { + const config = this.getConfig(); + + // Fetch tags para esta base + const tags = await fetchTags(config.id); + + // Fetch HST tags para resolución de nombres de grupos (si tiene grupos) + const hstTags = config.api.hasGroups + ? await fetchHstTags() + : []; + + // Fetch grupos (solo si esta base los soporta) + const groups = config.api.hasGroups + ? await fetchGroups() + : []; + + // Fetch bibliotecas (solo si esta base las soporta) + const libraries = config.api.hasLibraries + ? await fetchLibraries(config.id) + : []; + + this.setState({ + tags, + hstTags, + groups, + libraries, + library: 'all', + libraryMembers: new Set(), + group: 'all' + }); + } + + render(): void { + const state = this.getState(); + const config = this.getConfig(); + + // Verificar que la vista está soportada + if (!this.isViewSupported(state.view)) { + // Cambiar a vista por defecto + const defaultView = config.defaultView as ViewType; + this.setState({ view: defaultView }); + return; + } + + // Unmount current view + this.currentView?.unmount(); + + // Clear container + this.ctx.container.innerHTML = ''; + this.ctx.container.className = `content-area ${state.view}-view`; + + // Mount new view + switch (state.view) { + case 'grid': + this.currentView = new GridView(this.ctx.container, this.ctx.store, this.ctx.showDetail); + this.currentView.mount(); + break; + + case 'tree': + if (config.views.tree) { + this.currentView = new TreeView(this.ctx.container, this.ctx.store, this.ctx.showDetail); + this.currentView.mount(); + } + break; + + case 'graph': + if (config.views.graph) { + this.currentView = new GraphView(this.ctx.container, this.ctx.store, this.ctx.showDetail); + (this.currentView as GraphView).mount(); + } + break; + } + } + + renderSidebar(): void { + const container = this.ctx.leftPanel; + const state = this.getState(); + const config = this.getConfig(); + + // Si es vista de grafo, mostrar opciones de grafo + if (state.view === 'graph' && config.views.graph) { + container.classList.add('graph-mode'); + this.renderGraphOptions(container); + return; + } + + container.classList.remove('graph-mode'); + + // Si no tiene bibliotecas, vaciar sidebar + if (!config.api.hasLibraries) { + container.innerHTML = ''; + return; + } + + // Ordenar bibliotecas alfabéticamente + const sortedLibs = [...state.libraries].sort((a, b) => { + const nameA = a.name || a.name_es || a.alias || a.ref || ''; + const nameB = b.name || b.name_es || b.alias || b.ref || ''; + return nameA.localeCompare(nameB); + }); + + // Renderizar bibliotecas (simple - sin config por ahora) + container.innerHTML = ` +
+ ALL +
+ ${sortedLibs.map(lib => { + const icon = lib.img_thumb_url || lib.icon_url || ''; + const name = lib.name || lib.name_es || lib.alias || lib.ref || lib.mrf.slice(0, 6); + return ` +
+ ${icon ? `` : ''} + ${name.slice(0, 8)} +
+ `; + }).join('')} + `; + + // Bind library clicks + delegateEvent(container, '.lib-icon', 'click', async (_, target) => { + const library = target.dataset.lib || 'all'; + + if (library === 'all') { + this.setState({ library: 'all', libraryMembers: new Set() }); + } else { + const currentBase = this.getState().base; + const members = await fetchLibraryMembers(library, currentBase); + this.setState({ library, libraryMembers: new Set(members) }); + } + + this.renderSidebar(); + this.render(); + }); + } + + renderGroupsBar(): void { + const container = this.ctx.groupsBar; + const state = this.getState(); + const config = this.getConfig(); + + // Si no tiene grupos, vaciar + if (!config.api.hasGroups) { + container.innerHTML = ''; + return; + } + + // Usar hstTags para resolución de nombres + const nameMap = createNameMap(state.hstTags, state.lang); + + // Contar tags por grupo + const counts = new Map(); + state.tags.forEach(tag => { + const group = tag.set_hst || 'sin-grupo'; + counts.set(group, (counts.get(group) || 0) + 1); + }); + + // Ordenar por count y tomar top 20 + const sorted = Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20); + + container.innerHTML = ` + + ${sorted.map(([groupMrf, count]) => { + const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap); + return ` + + `; + }).join('')} + `; + + // Bind group clicks + delegateEvent(container, '.group-btn', 'click', (_, target) => { + const group = target.dataset.group || 'all'; + this.setState({ group }); + this.renderGroupsBar(); + this.render(); + }); + } + + private renderGraphOptions(container: HTMLElement): void { + // TODO: Extraer a componente separado + const state = this.getState(); + const { graphSettings, tags, graphEdges } = state; + + container.innerHTML = ` +
+
+
+ Nodos + ${tags.length} +
+
+ Edges + ${graphEdges.length} +
+
+
+
Visualización
+ + +
+
+ Nodo + ${graphSettings.nodeSize}px +
+ +
+
+
+ Distancia + ${graphSettings.linkDist}px +
+ +
+
+
+ `; + + this.bindGraphOptionEvents(container); + } + + private bindGraphOptionEvents(container: HTMLElement): void { + // Show images checkbox + const showImgCb = container.querySelector('#graph-show-img'); + showImgCb?.addEventListener('change', () => { + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, showImg: showImgCb.checked } + }); + this.render(); + }); + + // Show labels checkbox + const showLblCb = container.querySelector('#graph-show-lbl'); + showLblCb?.addEventListener('change', () => { + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, showLbl: showLblCb.checked } + }); + this.render(); + }); + + // Node size slider + const nodeSizeSlider = container.querySelector('#graph-node-size'); + const nodeSizeVal = container.querySelector('#node-size-val'); + nodeSizeSlider?.addEventListener('input', () => { + const size = parseInt(nodeSizeSlider.value, 10); + if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`; + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, nodeSize: size } + }); + this.render(); + }); + + // Link distance slider + const linkDistSlider = container.querySelector('#graph-link-dist'); + const linkDistVal = container.querySelector('#link-dist-val'); + linkDistSlider?.addEventListener('input', () => { + const dist = parseInt(linkDistSlider.value, 10); + if (linkDistVal) linkDistVal.textContent = `${dist}px`; + const state = this.getState(); + this.setState({ + graphSettings: { ...state.graphSettings, linkDist: dist } + }); + this.render(); + }); + } +} + +export default StandardModule; diff --git a/deck-frontend/backups/20260113_221902/src/modules/standard/index.ts b/deck-frontend/backups/20260113_221902/src/modules/standard/index.ts new file mode 100644 index 0000000..d1cb866 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/modules/standard/index.ts @@ -0,0 +1 @@ +export { StandardModule, default } from './StandardModule.ts'; diff --git a/deck-frontend/backups/20260113_221902/src/router/index.ts b/deck-frontend/backups/20260113_221902/src/router/index.ts new file mode 100644 index 0000000..e6dd489 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/router/index.ts @@ -0,0 +1 @@ +export { Router } from './router.ts'; diff --git a/deck-frontend/backups/20260113_221902/src/router/router.ts b/deck-frontend/backups/20260113_221902/src/router/router.ts new file mode 100644 index 0000000..27a33a9 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/router/router.ts @@ -0,0 +1,62 @@ +import type { Store } from '@/state/store.ts'; +import type { AppState, BaseType, ViewType } from '@/types/index.ts'; + +const VALID_BASES: BaseType[] = ['hst', 'flg', 'itm', 'loc', 'ply']; +const VALID_VIEWS: ViewType[] = ['grid', 'tree', 'graph']; + +export class Router { + private store: Store; + private onNavigate: () => void; + + constructor(store: Store, onNavigate: () => void) { + this.store = store; + this.onNavigate = onNavigate; + + window.addEventListener('hashchange', () => this.handleHashChange()); + } + + parseHash(): void { + const hash = window.location.hash + .replace(/^#\/?/, '') + .replace(/\/?$/, '') + .split('/') + .filter(Boolean); + + const state = this.store.getState(); + let base = state.base; + let view = state.view; + + if (hash[0] && VALID_BASES.includes(hash[0].toLowerCase() as BaseType)) { + base = hash[0].toLowerCase() as BaseType; + } + + if (hash[1] && VALID_VIEWS.includes(hash[1].toLowerCase() as ViewType)) { + view = hash[1].toLowerCase() as ViewType; + } + + this.store.setState({ base, view }); + } + + updateHash(): void { + const state = this.store.getState(); + const parts: string[] = [state.base]; + if (state.view !== 'grid') { + parts.push(state.view); + } + window.location.hash = '/' + parts.join('/') + '/'; + } + + private handleHashChange(): void { + this.parseHash(); + this.onNavigate(); + } + + navigate(base?: BaseType, view?: ViewType): void { + const state = this.store.getState(); + this.store.setState({ + base: base ?? state.base, + view: view ?? state.view + }); + this.updateHash(); + } +} diff --git a/deck-frontend/backups/20260113_221902/src/state/index.ts b/deck-frontend/backups/20260113_221902/src/state/index.ts new file mode 100644 index 0000000..5d3a5c7 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/state/index.ts @@ -0,0 +1,35 @@ +import { createStore } from './store.ts'; +import type { AppState, EdgeType } from '@/types/index.ts'; +import { EDGE_COLORS } from '@/config/index.ts'; + +const initialState: AppState = { + base: 'hst', + lang: 'es', + view: 'grid', + search: '', + group: 'all', + library: 'all', + libraryMembers: new Set(), + selectionMode: false, + selected: new Set(), + selectedTag: null, + tags: [], + hstTags: [], + groups: [], + libraries: [], + graphEdges: [], + treeEdges: [], + graphFilters: { + cats: new Set(['hst'] as const), + edges: new Set(Object.keys(EDGE_COLORS) as EdgeType[]) + }, + graphSettings: { + nodeSize: 20, + linkDist: 80, + showImg: true, + showLbl: true + } +}; + +export const store = createStore(initialState); +export { createStore } from './store.ts'; diff --git a/deck-frontend/backups/20260113_221902/src/state/store.ts b/deck-frontend/backups/20260113_221902/src/state/store.ts new file mode 100644 index 0000000..865b837 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/state/store.ts @@ -0,0 +1,27 @@ +type Listener = (state: T, prevState: T) => void; + +export interface Store { + getState: () => Readonly; + setState: (partial: Partial) => void; + subscribe: (listener: Listener) => () => void; +} + +export function createStore(initialState: T): Store { + let state = { ...initialState }; + const listeners = new Set>(); + + return { + getState: (): Readonly => state, + + setState: (partial: Partial): void => { + const prevState = state; + state = { ...state, ...partial }; + listeners.forEach(fn => fn(state, prevState)); + }, + + subscribe: (listener: Listener): (() => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + } + }; +} diff --git a/deck-frontend/backups/20260113_221902/src/styles/main.css b/deck-frontend/backups/20260113_221902/src/styles/main.css new file mode 100644 index 0000000..84d3791 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/styles/main.css @@ -0,0 +1,701 @@ +/* === RESET & VARIABLES === */ +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #0a0a0f; + --bg-secondary: #12121a; + --bg-card: #1a1a24; + --border: #2a2a3a; + --text: #e0e0e0; + --text-muted: #888; + --accent: #7c8aff; + --card-width: 176px; + --card-img-height: 176px; +} + +html, body { height: 100%; overflow: hidden; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: var(--bg-secondary); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; } +::-webkit-scrollbar-thumb:hover { background: #444; } + +/* === APP LAYOUT === */ +.app { display: flex; flex-direction: column; height: 100vh; } + +/* === TOPBAR === */ +.topbar { + height: 50px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 16px; + gap: 12px; +} +.topbar-left { display: flex; align-items: center; gap: 10px; } +.topbar-center { flex: 1; display: flex; justify-content: center; gap: 16px; } +.topbar-right { display: flex; align-items: center; gap: 10px; } +.logo { font-weight: 700; font-size: 1.2em; color: var(--accent); letter-spacing: 1px; } + +/* === BUTTONS === */ +.btn { + padding: 7px 14px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.8em; + font-weight: 500; + transition: all 0.15s ease; +} +.btn:hover { border-color: var(--accent); color: var(--text); } +.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-sm { padding: 5px 10px; font-size: 0.75em; } + +.search-input { + width: 300px; + padding: 9px 14px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 0.9em; +} +.search-input:focus { outline: none; border-color: var(--accent); } +.search-input::placeholder { color: var(--text-muted); } + +.base-buttons { display: flex; gap: 2px; background: var(--bg-card); border-radius: 6px; padding: 3px; } +.base-btn { + padding: 6px 14px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.8em; + font-weight: 600; + transition: all 0.15s ease; +} +.base-btn:hover { color: var(--text); } +.base-btn.active { background: var(--accent); color: #fff; } + +/* === VIEW BAR === */ +.view-bar { + height: 40px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; +} +.view-bar-spacer { width: 120px; } + +/* === SEL/GET GROUP === */ +.sel-group { + display: flex; + align-items: center; + gap: 2px; + background: var(--bg-card); + border-radius: 6px; + padding: 3px; +} +.sel-btn { + padding: 5px 12px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.75em; + font-weight: 600; + transition: all 0.15s ease; +} +.sel-btn:hover { color: var(--text); background: rgba(255,255,255,0.05); } +.sel-btn.active { background: var(--accent); color: #fff; } +#sel-count { + font-size: 0.7em; + color: var(--accent); + margin-left: 6px; + font-weight: 600; +} + +/* === GROUPS BAR === */ +.groups-bar { + height: 44px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 16px; + gap: 8px; + overflow-x: auto; +} +.group-btn { + padding: 6px 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 20px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.75em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + transition: all 0.15s ease; +} +.group-btn:hover { border-color: var(--accent); color: var(--text); } +.group-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +/* === MAIN LAYOUT === */ +.main-layout { display: flex; flex: 1; overflow: hidden; } + +/* === LEFT PANEL === */ +.left-panel { + width: 84px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + overflow-y: auto; + padding: 10px 6px; + flex-shrink: 0; +} +.lib-icon { + width: 68px; + height: 68px; + margin: 6px auto; + border-radius: 10px; + background: var(--bg-card); + border: 2px solid transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + transition: all 0.15s ease; + overflow: hidden; +} +.lib-icon:hover { border-color: var(--accent); } +.lib-icon.active { border-color: var(--accent); background: rgba(124, 138, 255, 0.15); } +.lib-icon img { width: 42px; height: 42px; object-fit: cover; border-radius: 6px; } +.lib-icon span { + font-size: 0.6em; + color: var(--text-muted); + margin-top: 4px; + text-align: center; + max-width: 60px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* === CENTER PANEL === */ +.center-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; } + +/* === VIEW TABS === */ +.view-tabs { display: flex; gap: 6px; } +.view-tab { + padding: 7px 20px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.85em; + font-weight: 500; + transition: all 0.15s ease; +} +.view-tab:hover { color: var(--text); background: var(--bg-card); } +.view-tab.active { background: var(--accent); color: #fff; } + +/* === CONTENT AREA === */ +.content-area { flex: 1; overflow: hidden; position: relative; } + +/* === GRID VIEW === */ +.grid-view { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 16px; + padding: 20px; + overflow-y: auto; + height: 100%; +} + +.card { + width: var(--card-width); + flex-shrink: 0; + flex-grow: 0; + background: var(--bg-card); + border-radius: 10px; + border: 1px solid var(--border); + overflow: hidden; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; + position: relative; +} +.card:hover { + border-color: var(--accent); + transform: translateY(-3px); + box-shadow: 0 8px 20px rgba(0,0,0,0.3); +} +.card.selected { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(124, 138, 255, 0.4); +} + +.card-checkbox { + position: absolute; + top: 10px; + left: 10px; + width: 24px; + height: 24px; + border-radius: 6px; + background: rgba(0,0,0,0.7); + border: 2px solid var(--border); + display: none; + align-items: center; + justify-content: center; + z-index: 5; + transition: all 0.15s ease; +} +.card-checkbox.visible { display: flex; } +.card-checkbox.checked { background: var(--accent); border-color: var(--accent); } +.card-checkbox.checked::after { content: "\2713"; color: #fff; font-size: 14px; font-weight: bold; } + +.card-image { + width: var(--card-width); + height: var(--card-img-height); + background: linear-gradient(145deg, #1a1a24 0%, #0a0a0f 100%); + position: relative; + overflow: hidden; +} + +.card-placeholder { + width: var(--card-width); + height: var(--card-img-height); + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5em; + font-weight: 700; + color: var(--accent); + opacity: 0.5; + text-transform: uppercase; + background: linear-gradient(145deg, #1a1a24 0%, #0a0a0f 100%); +} + +.card-img { + width: var(--card-width); + height: var(--card-img-height); + object-fit: cover; +} + +.card-body { padding: 12px; } +.card-ref { + font-size: 0.75em; + color: var(--accent); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.card-name { + font-size: 0.85em; + color: var(--text); + margin-top: 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; +} + +/* === TREE VIEW === */ +.tree-view { + padding: 20px; + overflow-y: auto; + height: 100%; +} +.tree-group { margin-bottom: 12px; } +.tree-header { + display: flex; + align-items: center; + padding: 10px 12px; + cursor: pointer; + border-radius: 8px; + background: var(--bg-card); + transition: background 0.15s ease; +} +.tree-header:hover { background: var(--bg-secondary); } +.tree-toggle { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 1em; + font-weight: bold; + flex-shrink: 0; +} +.tree-group-name { flex: 1; font-weight: 500; } +.tree-count { + font-size: 0.75em; + color: var(--text-muted); + background: var(--bg-secondary); + padding: 4px 10px; + border-radius: 12px; +} +.tree-items { display: none; margin-left: 28px; margin-top: 4px; } +.tree-items.expanded { display: block; } +.tree-item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + border-radius: 6px; + margin: 3px 0; + gap: 10px; + transition: background 0.15s ease; +} +.tree-item:hover { background: var(--bg-card); } +.tree-item.selected { background: rgba(124,138,255,0.15); } +.tree-img { + width: 32px; + height: 32px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; + background: var(--bg-card); +} +.tree-placeholder { + width: 32px; + height: 32px; + border-radius: 6px; + background: var(--bg-card); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9em; + font-weight: 600; + color: var(--accent); +} +.tree-name { font-size: 0.9em; } + +/* === GRAPH VIEW === */ +.graph-view { + width: 100%; + height: 100%; + position: relative; +} +.graph-view svg { width: 100%; height: 100%; display: block; background: var(--bg); } +.node { cursor: pointer; } +.node text { fill: var(--text-muted); pointer-events: none; font-size: 11px; } +.node.selected circle { stroke: var(--accent); stroke-width: 4; } +.link { stroke-opacity: 0.5; } + +/* === DETAIL PANEL === */ +.detail-panel { + width: 0; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + overflow-y: auto; + overflow-x: hidden; + transition: width 0.3s ease; + flex-shrink: 0; +} +.detail-panel.open { width: 360px; } + +.detail-header { + position: relative; + width: 100%; + height: 220px; + background: linear-gradient(145deg, var(--bg-card) 0%, var(--bg) 100%); + overflow: hidden; +} +.detail-placeholder { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 5em; + font-weight: 700; + color: var(--accent); + opacity: 0.4; + text-transform: uppercase; +} +.detail-img { + width: 100%; + height: 100%; + object-fit: cover; +} +.detail-close { + position: absolute; + top: 12px; + right: 12px; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(0,0,0,0.7); + border: none; + color: #fff; + cursor: pointer; + font-size: 20px; + z-index: 5; + transition: background 0.15s ease; +} +.detail-close:hover { background: rgba(0,0,0,0.9); } + +.detail-body { padding: 20px; } +.detail-ref { + font-size: 1.2em; + color: var(--accent); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; +} +.detail-mrf { + font-size: 0.7em; + color: var(--text-muted); + margin-top: 8px; + font-family: monospace; + word-break: break-all; + cursor: pointer; + padding: 8px 10px; + background: var(--bg-card); + border-radius: 6px; + transition: color 0.15s ease; +} +.detail-mrf:hover { color: var(--accent); } +.detail-name { font-size: 1.3em; color: var(--text); margin-top: 16px; font-weight: 500; } +.detail-desc { font-size: 0.9em; color: var(--text-muted); margin-top: 12px; line-height: 1.7; } + +.detail-section { margin-top: 24px; } +.detail-section h4 { + font-size: 0.75em; + color: var(--text-muted); + text-transform: uppercase; + margin-bottom: 12px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.chip-list { display: flex; flex-wrap: wrap; gap: 8px; } +.tag-chip { + padding: 7px 12px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.8em; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s ease; +} +.tag-chip:hover { border-color: var(--accent); color: var(--text); } + +/* === TOAST === */ +.toast { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: var(--accent); + color: #fff; + padding: 14px 28px; + border-radius: 10px; + font-size: 0.9em; + font-weight: 500; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 1000; + pointer-events: none; +} +.toast.show { opacity: 1; } + +/* === MODAL === */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.85); + display: none; + align-items: center; + justify-content: center; + z-index: 100; +} +.modal.open { display: flex; } +.modal-content { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 14px; + width: 90%; + max-width: 620px; + max-height: 80vh; + overflow: hidden; +} +.modal-header { + padding: 18px 22px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} +.modal-header h3 { font-size: 1.15em; color: var(--text); font-weight: 600; } +.modal-close { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1.5em; + padding: 4px 8px; +} +.modal-close:hover { color: var(--text); } +.modal-body { padding: 22px; overflow-y: auto; max-height: calc(80vh - 65px); } +.api-item { margin-bottom: 18px; } +.api-endpoint { + font-family: monospace; + font-size: 0.9em; + color: var(--accent); + background: var(--bg-card); + padding: 12px 14px; + border-radius: 8px; +} +.api-desc { font-size: 0.85em; color: var(--text-muted); margin-top: 8px; } + +/* === EMPTY STATE === */ +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + gap: 16px; + padding: 40px; +} + +/* === LOADING === */ +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + gap: 14px; + font-size: 1em; +} +.loading::after { + content: ""; + width: 28px; + height: 28px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* === SELECT === */ +select { + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-card); + color: var(--text); + font-size: 0.8em; + cursor: pointer; +} +select:focus { outline: none; border-color: var(--accent); } + +/* === GRAPH OPTIONS PANEL === */ +.graph-options { + padding: 10px; + overflow-y: auto; + width: 180px; +} +.graph-section { + margin-bottom: 16px; +} +.graph-section-title { + font-size: 0.7em; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} +.graph-stat { + display: flex; + justify-content: space-between; + font-size: 0.75em; + color: var(--text-muted); + margin-bottom: 4px; +} +.graph-stat-value { + color: var(--text); + font-weight: 600; +} +.graph-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.75em; + color: var(--text-muted); + margin-bottom: 6px; + cursor: pointer; +} +.graph-checkbox:hover { color: var(--text); } +.graph-checkbox input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--accent); + cursor: pointer; +} +.graph-checkbox .color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 4px; +} +.graph-slider { + margin-bottom: 12px; +} +.graph-slider-label { + display: flex; + justify-content: space-between; + font-size: 0.7em; + color: var(--text-muted); + margin-bottom: 4px; +} +.graph-slider-value { + color: var(--text); + font-weight: 600; +} +.graph-slider input[type="range"] { + width: 100%; + height: 4px; + background: var(--border); + border-radius: 2px; + -webkit-appearance: none; + cursor: pointer; +} +.graph-slider input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; +} +.left-panel.graph-mode { + width: 180px; +} diff --git a/deck-frontend/backups/20260113_221902/src/types/graph.ts b/deck-frontend/backups/20260113_221902/src/types/graph.ts new file mode 100644 index 0000000..ceaaa4b --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/types/graph.ts @@ -0,0 +1,37 @@ +export type EdgeType = + | 'relation' + | 'specialization' + | 'mirror' + | 'dependency' + | 'sequence' + | 'composition' + | 'hierarchy' + | 'library' + | 'contextual' + | 'association'; + +export type CategoryKey = 'hst' | 'spe' | 'vue' | 'vsn' | 'msn' | 'flg'; + +export interface GraphEdge { + mrf_a: string; + mrf_b: string; + edge_type: EdgeType; + weight?: number; +} + +export interface TreeEdge { + mrf_parent: string; + mrf_child: string; +} + +export interface GraphNode { + id: string; + ref: string; + name: string; + img: string; + cat: CategoryKey; + x?: number; + y?: number; + fx?: number | null; + fy?: number | null; +} diff --git a/deck-frontend/backups/20260113_221902/src/types/index.ts b/deck-frontend/backups/20260113_221902/src/types/index.ts new file mode 100644 index 0000000..5d577bd --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/types/index.ts @@ -0,0 +1,16 @@ +export type { Tag, Group, Library, ChildTag, RelatedTag } from './tag.ts'; +export type { + EdgeType, + CategoryKey, + GraphEdge, + TreeEdge, + GraphNode +} from './graph.ts'; +export type { + ViewType, + BaseType, + LangType, + GraphFilters, + GraphSettings, + AppState +} from './state.ts'; diff --git a/deck-frontend/backups/20260113_221902/src/types/state.ts b/deck-frontend/backups/20260113_221902/src/types/state.ts new file mode 100644 index 0000000..0fc8a07 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/types/state.ts @@ -0,0 +1,53 @@ +import type { Tag, Group, Library } from './tag.ts'; +import type { GraphEdge, TreeEdge, CategoryKey, EdgeType } from './graph.ts'; + +export type ViewType = 'grid' | 'tree' | 'graph'; +export type BaseType = + | 'hst' | 'flg' | 'itm' | 'loc' | 'ply' // Taxonomía (public) + | 'mth' | 'atc' // Registro (secretaria_clara, production_alfred) + | 'mst' | 'bck' // Maestros (secretaria_clara) + | 'mail' | 'chat' // Comunicación (mail_manager, context_manager) + | 'key' | 'mindlink'; // Servicios +export type LangType = 'es' | 'en' | 'ch'; + +export interface GraphFilters { + cats: Set; + edges: Set; +} + +export interface GraphSettings { + nodeSize: number; + linkDist: number; + showImg: boolean; + showLbl: boolean; +} + +export interface AppState { + // Navigation + base: BaseType; + lang: LangType; + view: ViewType; + + // Filters + search: string; + group: string; + library: string; + libraryMembers: Set; + + // Selection + selectionMode: boolean; + selected: Set; + selectedTag: Tag | null; + + // Data + tags: Tag[]; + hstTags: Tag[]; // HST tags for group name resolution + groups: Group[]; + libraries: Library[]; + graphEdges: GraphEdge[]; + treeEdges: TreeEdge[]; + + // Graph-specific + graphFilters: GraphFilters; + graphSettings: GraphSettings; +} diff --git a/deck-frontend/backups/20260113_221902/src/types/tag.ts b/deck-frontend/backups/20260113_221902/src/types/tag.ts new file mode 100644 index 0000000..425df07 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/types/tag.ts @@ -0,0 +1,42 @@ +export interface Tag { + mrf: string; + ref: string; + name_es?: string; + name_en?: string; + name_ch?: string; + txt?: string; + alias?: string; + set_hst?: string; + img_url?: string; + img_thumb_url?: string; +} + +export interface Group { + mrf: string; + ref: string; + name_es?: string; + name_en?: string; +} + +export interface Library { + mrf: string; + ref?: string; + name?: string; + name_es?: string; + name_en?: string; + alias?: string; + icon_url?: string; + img_thumb_url?: string; + member_count?: number; +} + +export interface ChildTag { + mrf: string; + ref?: string; + alias?: string; + name_es?: string; +} + +export interface RelatedTag extends ChildTag { + edge_type: string; +} diff --git a/deck-frontend/backups/20260113_221902/src/utils/clipboard.ts b/deck-frontend/backups/20260113_221902/src/utils/clipboard.ts new file mode 100644 index 0000000..ab117d1 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/utils/clipboard.ts @@ -0,0 +1,14 @@ +import { toast } from './toast.ts'; + +export async function copyToClipboard(text: string, message?: string): Promise { + try { + await navigator.clipboard.writeText(text); + toast(message || 'Copiado'); + } catch { + toast('Error al copiar'); + } +} + +export function copyMrf(mrf: string): void { + copyToClipboard(mrf, `MRF copiado: ${mrf.slice(0, 8)}...`); +} diff --git a/deck-frontend/backups/20260113_221902/src/utils/dom.ts b/deck-frontend/backups/20260113_221902/src/utils/dom.ts new file mode 100644 index 0000000..50cfa99 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/utils/dom.ts @@ -0,0 +1,44 @@ +export const $ = ( + selector: string, + parent: ParentNode = document +): T | null => parent.querySelector(selector); + +export const $$ = ( + selector: string, + parent: ParentNode = document +): T[] => Array.from(parent.querySelectorAll(selector)); + +export function createElement( + tag: K, + attrs?: Record, + children?: (HTMLElement | string)[] +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + if (attrs) { + Object.entries(attrs).forEach(([key, value]) => { + if (key === 'className') el.className = value; + else if (key.startsWith('data-')) el.setAttribute(key, value); + else el.setAttribute(key, value); + }); + } + if (children) { + children.forEach(child => { + el.append(typeof child === 'string' ? child : child); + }); + } + return el; +} + +export function delegateEvent( + container: HTMLElement, + selector: string, + eventType: string, + handler: (event: T, target: HTMLElement) => void +): void { + container.addEventListener(eventType, (event) => { + const target = (event.target as HTMLElement).closest(selector); + if (target && container.contains(target)) { + handler(event as T, target); + } + }); +} diff --git a/deck-frontend/backups/20260113_221902/src/utils/filters.ts b/deck-frontend/backups/20260113_221902/src/utils/filters.ts new file mode 100644 index 0000000..b192126 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/utils/filters.ts @@ -0,0 +1,39 @@ +import type { Tag, LangType } from '@/types/index.ts'; +import { getName } from './i18n.ts'; + +export interface FilterOptions { + search: string; + group: string; + library: string; + libraryMembers: Set; + lang: LangType; +} + +export function filterTags(tags: Tag[], options: FilterOptions): Tag[] { + const { search, group, library, libraryMembers, lang } = options; + const q = search.toLowerCase(); + + return tags.filter(tag => { + // Library filter + if (library !== 'all' && !libraryMembers.has(tag.mrf)) { + return false; + } + + // Group filter + if (group !== 'all' && tag.set_hst !== group) { + return false; + } + + // Search filter + if (q) { + const name = getName(tag, lang).toLowerCase(); + const ref = (tag.ref || '').toLowerCase(); + const alias = (tag.alias || '').toLowerCase(); + if (!name.includes(q) && !ref.includes(q) && !alias.includes(q)) { + return false; + } + } + + return true; + }); +} diff --git a/deck-frontend/backups/20260113_221902/src/utils/i18n.ts b/deck-frontend/backups/20260113_221902/src/utils/i18n.ts new file mode 100644 index 0000000..1d1b8d2 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/utils/i18n.ts @@ -0,0 +1,42 @@ +import type { Tag, LangType } from '@/types/index.ts'; + +export function getName(tag: Tag, lang: LangType): string { + if (lang === 'es' && tag.name_es) return tag.name_es; + if (lang === 'en' && tag.name_en) return tag.name_en; + if (lang === 'ch' && tag.name_ch) return tag.name_ch; + return tag.name_es || tag.name_en || tag.alias || tag.ref || tag.mrf.slice(0, 8); +} + +// Create a map of mrf -> display name for resolving groups +export function createNameMap(tags: Tag[], lang: LangType): Map { + const map = new Map(); + tags.forEach(tag => { + map.set(tag.mrf, getName(tag, lang)); + }); + return map; +} + +// Resolve a group mrf to its display name +export function resolveGroupName(mrf: string | undefined, nameMap: Map): string { + if (!mrf) return 'Sin grupo'; + return nameMap.get(mrf) || mrf.slice(0, 8); +} + +const ATC_BASE = 'https://atc.tzzrdeck.me'; + +function resolveImgUrl(url: string | undefined): string { + if (!url) return ''; + // Relative paths (e.g., "thumbs/xxx.png") need ATC base + if (url && !url.startsWith('http')) { + return `${ATC_BASE}/${url}`; + } + return url; +} + +export function getImg(tag: Tag): string { + return resolveImgUrl(tag.img_thumb_url); +} + +export function getFullImg(tag: Tag): string { + return resolveImgUrl(tag.img_url) || resolveImgUrl(tag.img_thumb_url); +} diff --git a/deck-frontend/backups/20260113_221902/src/utils/index.ts b/deck-frontend/backups/20260113_221902/src/utils/index.ts new file mode 100644 index 0000000..a0131b6 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/utils/index.ts @@ -0,0 +1,5 @@ +export { $, $$, createElement, delegateEvent } from './dom.ts'; +export { getName, getImg, getFullImg, createNameMap, resolveGroupName } from './i18n.ts'; +export { filterTags, type FilterOptions } from './filters.ts'; +export { copyToClipboard, copyMrf } from './clipboard.ts'; +export { toast } from './toast.ts'; diff --git a/deck-frontend/backups/20260113_221902/src/utils/toast.ts b/deck-frontend/backups/20260113_221902/src/utils/toast.ts new file mode 100644 index 0000000..e5258ee --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/utils/toast.ts @@ -0,0 +1,21 @@ +let toastEl: HTMLElement | null = null; +let toastTimeout: number | null = null; + +export function toast(message: string, duration = 2000): void { + if (!toastEl) { + toastEl = document.createElement('div'); + toastEl.className = 'toast'; + document.body.appendChild(toastEl); + } + + if (toastTimeout) { + clearTimeout(toastTimeout); + } + + toastEl.textContent = message; + toastEl.classList.add('show'); + + toastTimeout = window.setTimeout(() => { + toastEl?.classList.remove('show'); + }, duration); +} diff --git a/deck-frontend/backups/20260113_221902/src/views/DetailPanel/DetailPanel.ts b/deck-frontend/backups/20260113_221902/src/views/DetailPanel/DetailPanel.ts new file mode 100644 index 0000000..70833bf --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/views/DetailPanel/DetailPanel.ts @@ -0,0 +1,115 @@ +import { View } from '../View.ts'; +import { getName, getFullImg, copyMrf, delegateEvent } from '@/utils/index.ts'; +import { fetchChildren, fetchRelated } from '@/api/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, Tag } from '@/types/index.ts'; + +export class DetailPanel extends View { + private panelEl: HTMLElement; + + constructor( + container: HTMLElement, + store: Store + ) { + super(container, store); + this.panelEl = container; + } + + async showDetail(mrf: string): Promise { + const state = this.getState(); + const tag = state.tags.find(t => t.mrf === mrf); + if (!tag) return; + + this.setState({ selectedTag: tag }); + this.panelEl.classList.add('open'); + await this.renderDetail(tag); + } + + close(): void { + this.panelEl.classList.remove('open'); + this.setState({ selectedTag: null }); + } + + render(): void { + const state = this.getState(); + if (state.selectedTag) { + this.renderDetail(state.selectedTag); + } + } + + private async renderDetail(tag: Tag): Promise { + const state = this.getState(); + const img = getFullImg(tag); + const name = getName(tag, state.lang); + + this.panelEl.innerHTML = ` +
+ ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 2) || 'T'}
` + } + +
+
+
${tag.ref || ''}
+
${tag.mrf}
+
${name}
+
${tag.txt || tag.alias || ''}
+ + +
+ `; + + this.bindDetailEvents(); + await this.loadRelations(tag.mrf); + } + + private bindDetailEvents(): void { + const closeBtn = this.panelEl.querySelector('.detail-close'); + closeBtn?.addEventListener('click', () => this.close()); + + const mrfEl = this.panelEl.querySelector('.detail-mrf'); + mrfEl?.addEventListener('click', () => { + const mrf = (mrfEl as HTMLElement).dataset.mrf; + if (mrf) copyMrf(mrf); + }); + + delegateEvent(this.panelEl, '.tag-chip', 'click', (_, target) => { + const mrf = target.dataset.mrf; + if (mrf) this.showDetail(mrf); + }); + } + + private async loadRelations(mrf: string): Promise { + const [children, related] = await Promise.all([ + fetchChildren(mrf), + fetchRelated(mrf) + ]); + + const childrenSection = this.panelEl.querySelector('#children-section') as HTMLElement; + const childrenList = this.panelEl.querySelector('#children-list') as HTMLElement; + if (children.length > 0) { + childrenSection.style.display = 'block'; + childrenList.innerHTML = children.map(c => { + const label = c.name_es || c.alias || c.ref || c.mrf.slice(0, 8); + return `${label}`; + }).join(''); + } + + const relatedSection = this.panelEl.querySelector('#related-section') as HTMLElement; + const relatedList = this.panelEl.querySelector('#related-list') as HTMLElement; + if (related.length > 0) { + relatedSection.style.display = 'block'; + relatedList.innerHTML = related.map(r => { + const label = r.name_es || r.alias || r.ref || r.mrf.slice(0, 8); + return `${label}`; + }).join(''); + } + } +} diff --git a/deck-frontend/backups/20260113_221902/src/views/GraphView/GraphView.ts b/deck-frontend/backups/20260113_221902/src/views/GraphView/GraphView.ts new file mode 100644 index 0000000..ff63b4c --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/views/GraphView/GraphView.ts @@ -0,0 +1,269 @@ +import { View } from '../View.ts'; +import { filterTags, getName, getImg } from '@/utils/index.ts'; +import { fetchGraphEdges, fetchTreeEdges } from '@/api/index.ts'; +import { CATS, EDGE_COLORS } from '@/config/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, GraphNode, CategoryKey, EdgeType } from '@/types/index.ts'; + +type D3Module = typeof import('d3'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type D3Selection = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type D3Simulation = any; + +export class GraphView extends View { + private d3: D3Module | null = null; + private simulation: D3Simulation | null = null; + private showDetail: (mrf: string) => void; + + constructor( + container: HTMLElement, + store: Store, + showDetail: (mrf: string) => void + ) { + super(container, store); + this.showDetail = showDetail; + } + + async mount(): Promise { + this.container.innerHTML = '
Cargando grafo...
'; + + // Lazy load D3 + if (!this.d3) { + this.d3 = await import('d3'); + } + + // Load graph data + const state = this.getState(); + if (state.graphEdges.length === 0) { + const [graphEdges, treeEdges] = await Promise.all([ + fetchGraphEdges(), + fetchTreeEdges() + ]); + this.store.setState({ graphEdges, treeEdges }); + } + + this.render(); + } + + render(): void { + if (!this.d3) return; + const d3 = this.d3; + const state = this.getState(); + + // Build nodes from filtered tags + const filtered = filterTags(state.tags, { + search: state.search, + group: state.group, + library: state.library, + libraryMembers: state.libraryMembers, + lang: state.lang + }); + + const nodeMap = new Map(); + filtered.forEach(tag => { + // Determinar categoría del tag (por prefijo de mrf o default 'hst') + const tagCat = this.getTagCategory(tag.mrf); + + // Filtrar por categorías activas + if (!state.graphFilters.cats.has(tagCat)) return; + + nodeMap.set(tag.mrf, { + id: tag.mrf, + ref: tag.alias || tag.ref || tag.mrf.slice(0, 8), + name: getName(tag, state.lang), + img: getImg(tag), + cat: tagCat + }); + }); + + // Build edges + interface GraphLink { + source: string | GraphNode; + target: string | GraphNode; + type: EdgeType; + weight: number; + } + const edges: GraphLink[] = []; + + state.graphEdges.forEach(e => { + if (nodeMap.has(e.mrf_a) && nodeMap.has(e.mrf_b)) { + if (state.graphFilters.edges.has(e.edge_type)) { + edges.push({ + source: e.mrf_a, + target: e.mrf_b, + type: e.edge_type, + weight: e.weight || 1 + }); + } + } + }); + + state.treeEdges.forEach(e => { + if (nodeMap.has(e.mrf_parent) && nodeMap.has(e.mrf_child)) { + if (state.graphFilters.edges.has('hierarchy')) { + edges.push({ + source: e.mrf_parent, + target: e.mrf_child, + type: 'hierarchy', + weight: 1 + }); + } + } + }); + + const nodes = Array.from(nodeMap.values()); + + if (nodes.length === 0) { + this.container.innerHTML = '
Sin nodos para mostrar
'; + return; + } + + // Clear and create SVG + this.container.innerHTML = ''; + const width = this.container.clientWidth; + const height = this.container.clientHeight || 600; + + const svg: D3Selection = d3.select(this.container) + .append('svg') + .attr('width', '100%') + .attr('height', '100%') + .attr('viewBox', `0 0 ${width} ${height}`); + + const g = svg.append('g'); + + // Zoom + const zoom = d3.zoom() + .scaleExtent([0.1, 4]) + .on('zoom', (event: { transform: string }) => { + g.attr('transform', event.transform); + }); + + svg.call(zoom); + + // Simulation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.simulation = d3.forceSimulation(nodes as any) + .force('link', d3.forceLink(edges) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .id((d: any) => d.id) + .distance(state.graphSettings.linkDist)) + .force('charge', d3.forceManyBody().strength(-150)) + .force('center', d3.forceCenter(width / 2, height / 2)) + .force('collision', d3.forceCollide().radius(state.graphSettings.nodeSize + 5)); + + // Links + const link = g.append('g') + .selectAll('line') + .data(edges) + .join('line') + .attr('stroke', (d: GraphLink) => EDGE_COLORS[d.type] || '#999') + .attr('stroke-width', (d: GraphLink) => Math.sqrt(d.weight)) + .attr('stroke-opacity', 0.6); + + // Nodes + const node = g.append('g') + .selectAll('g') + .data(nodes) + .join('g') + .attr('cursor', 'pointer') + .call(this.createDrag(d3, this.simulation)); + + const nodeSize = state.graphSettings.nodeSize; + + if (state.graphSettings.showImg) { + node.append('image') + .attr('xlink:href', (d: GraphNode) => d.img || '') + .attr('width', nodeSize) + .attr('height', nodeSize) + .attr('x', -nodeSize / 2) + .attr('y', -nodeSize / 2) + .attr('clip-path', 'circle(50%)'); + + // Fallback for nodes without image + node.filter((d: GraphNode) => !d.img) + .append('circle') + .attr('r', nodeSize / 2) + .attr('fill', (d: GraphNode) => CATS[d.cat]?.color || '#7c8aff'); + } else { + node.append('circle') + .attr('r', nodeSize / 2) + .attr('fill', (d: GraphNode) => CATS[d.cat]?.color || '#7c8aff'); + } + + if (state.graphSettings.showLbl) { + node.append('text') + .text((d: GraphNode) => d.ref) + .attr('dy', nodeSize / 2 + 12) + .attr('text-anchor', 'middle') + .attr('font-size', 10) + .attr('fill', 'var(--text-primary)'); + } + + node.on('click', (_: MouseEvent, d: GraphNode) => { + if (state.selectionMode) { + const newSelected = new Set(state.selected); + if (newSelected.has(d.id)) { + newSelected.delete(d.id); + } else { + newSelected.add(d.id); + } + this.store.setState({ selected: newSelected }); + } else { + this.showDetail(d.id); + } + }); + + // Tick + this.simulation.on('tick', () => { + link + .attr('x1', (d: GraphLink) => (d.source as GraphNode).x!) + .attr('y1', (d: GraphLink) => (d.source as GraphNode).y!) + .attr('x2', (d: GraphLink) => (d.target as GraphNode).x!) + .attr('y2', (d: GraphLink) => (d.target as GraphNode).y!); + + node.attr('transform', (d: GraphNode) => `translate(${d.x},${d.y})`); + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private createDrag(d3: D3Module, simulation: D3Simulation): any { + return d3.drag() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .on('start', (event: any, d: any) => { + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .on('drag', (event: any, d: any) => { + d.fx = event.x; + d.fy = event.y; + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .on('end', (event: any, d: any) => { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + }); + } + + /** + * Determina la categoría de un tag basándose en el prefijo del MRF + * CategoryKey: 'hst' | 'spe' | 'vue' | 'vsn' | 'msn' | 'flg' + */ + private getTagCategory(mrf: string): CategoryKey { + // Mapeo de prefijos a categorías + if (mrf.startsWith('spe_')) return 'spe'; // Specs + if (mrf.startsWith('vue_')) return 'vue'; // Vue (Values) + if (mrf.startsWith('vsn_')) return 'vsn'; // Visions + if (mrf.startsWith('msn_')) return 'msn'; // Missions + if (mrf.startsWith('flg_')) return 'flg'; // Flags + return 'hst'; // Default: Hashtags + } + + unmount(): void { + this.simulation?.stop(); + super.unmount(); + } +} diff --git a/deck-frontend/backups/20260113_221902/src/views/GridView/GridView.ts b/deck-frontend/backups/20260113_221902/src/views/GridView/GridView.ts new file mode 100644 index 0000000..3380470 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/views/GridView/GridView.ts @@ -0,0 +1,76 @@ +import { View } from '../View.ts'; +import { filterTags, getName, getImg, delegateEvent } from '@/utils/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState } from '@/types/index.ts'; + +export class GridView extends View { + private showDetail: (mrf: string) => void; + + constructor( + container: HTMLElement, + store: Store, + showDetail: (mrf: string) => void + ) { + super(container, store); + this.showDetail = showDetail; + } + + render(): void { + const state = this.getState(); + const filtered = filterTags(state.tags, { + search: state.search, + group: state.group, + library: state.library, + libraryMembers: state.libraryMembers, + lang: state.lang + }); + + if (filtered.length === 0) { + this.container.innerHTML = '
Sin resultados
'; + return; + } + + this.container.innerHTML = filtered.map(tag => { + const img = getImg(tag); + const name = getName(tag, state.lang); + const isSelected = state.selected.has(tag.mrf); + + return ` +
+ ${state.selectionMode ? ` + + ` : ''} + ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 2) || 'T'}
` + } +
${name}
+
+ `; + }).join(''); + + this.bindEvents(); + } + + private bindEvents(): void { + delegateEvent(this.container, '.card', 'click', (_, target) => { + const mrf = target.dataset.mrf; + if (!mrf) return; + + // Obtener estado FRESCO en cada click (no del closure) + const state = this.getState(); + + if (state.selectionMode) { + const newSelected = new Set(state.selected); + if (newSelected.has(mrf)) { + newSelected.delete(mrf); + } else { + newSelected.add(mrf); + } + this.setState({ selected: newSelected }); + } else { + this.showDetail(mrf); + } + }); + } +} diff --git a/deck-frontend/backups/20260113_221902/src/views/TreeView/TreeView.ts b/deck-frontend/backups/20260113_221902/src/views/TreeView/TreeView.ts new file mode 100644 index 0000000..375f6a7 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/views/TreeView/TreeView.ts @@ -0,0 +1,110 @@ +import { View } from '../View.ts'; +import { filterTags, getName, getImg, delegateEvent, createNameMap, resolveGroupName } from '@/utils/index.ts'; +import type { Store } from '@/state/store.ts'; +import type { AppState, Tag } from '@/types/index.ts'; + +export class TreeView extends View { + private showDetail: (mrf: string) => void; + private expanded: Set = new Set(); + + constructor( + container: HTMLElement, + store: Store, + showDetail: (mrf: string) => void + ) { + super(container, store); + this.showDetail = showDetail; + } + + render(): void { + const state = this.getState(); + // Use hstTags for group name resolution (set_hst points to hst tags) + const nameMap = createNameMap(state.hstTags, state.lang); + const filtered = filterTags(state.tags, { + search: state.search, + group: state.group, + library: state.library, + libraryMembers: state.libraryMembers, + lang: state.lang + }); + + // Group by set_hst + const groups = new Map(); + filtered.forEach(tag => { + const group = tag.set_hst || 'sin-grupo'; + if (!groups.has(group)) groups.set(group, []); + groups.get(group)!.push(tag); + }); + + if (groups.size === 0) { + this.container.innerHTML = '
Sin resultados
'; + return; + } + + this.container.innerHTML = Array.from(groups.entries()).map(([groupMrf, tags]) => { + const isExpanded = this.expanded.has(groupMrf); + const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap); + return ` +
+
+ ${isExpanded ? '−' : '+'} + ${groupName} + ${tags.length} +
+
+ ${tags.map(tag => { + const img = getImg(tag); + const name = getName(tag, state.lang); + const isSelected = state.selected.has(tag.mrf); + return ` +
+ ${img + ? `${tag.ref}` + : `
${tag.ref?.slice(0, 1) || 'T'}
` + } + ${name} +
+ `; + }).join('')} +
+
+ `; + }).join(''); + + this.bindEvents(); + } + + private bindEvents(): void { + delegateEvent(this.container, '.tree-header', 'click', (_, target) => { + const group = target.dataset.group; + if (!group) return; + + if (this.expanded.has(group)) { + this.expanded.delete(group); + } else { + this.expanded.add(group); + } + this.render(); + }); + + delegateEvent(this.container, '.tree-item', 'click', (_, target) => { + const mrf = target.dataset.mrf; + if (!mrf) return; + + // Obtener estado FRESCO en cada click (no del closure) + const state = this.getState(); + + if (state.selectionMode) { + const newSelected = new Set(state.selected); + if (newSelected.has(mrf)) { + newSelected.delete(mrf); + } else { + newSelected.add(mrf); + } + this.setState({ selected: newSelected }); + } else { + this.showDetail(mrf); + } + }); + } +} diff --git a/deck-frontend/backups/20260113_221902/src/views/View.ts b/deck-frontend/backups/20260113_221902/src/views/View.ts new file mode 100644 index 0000000..7697010 --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/views/View.ts @@ -0,0 +1,33 @@ +import type { Store } from '@/state/store.ts'; +import type { AppState } from '@/types/index.ts'; + +export abstract class View { + protected container: HTMLElement; + protected store: Store; + protected unsubscribe?: () => void; + + constructor(container: HTMLElement, store: Store) { + this.container = container; + this.store = store; + } + + abstract render(): void; + + mount(): void { + this.unsubscribe = this.store.subscribe(() => this.render()); + this.render(); + } + + unmount(): void { + this.unsubscribe?.(); + this.container.innerHTML = ''; + } + + protected getState(): AppState { + return this.store.getState(); + } + + protected setState(partial: Partial): void { + this.store.setState(partial); + } +} diff --git a/deck-frontend/backups/20260113_221902/src/views/index.ts b/deck-frontend/backups/20260113_221902/src/views/index.ts new file mode 100644 index 0000000..46261be --- /dev/null +++ b/deck-frontend/backups/20260113_221902/src/views/index.ts @@ -0,0 +1,5 @@ +export { View } from './View.ts'; +export { GridView } from './GridView/GridView.ts'; +export { TreeView } from './TreeView/TreeView.ts'; +export { GraphView } from './GraphView/GraphView.ts'; +export { DetailPanel } from './DetailPanel/DetailPanel.ts'; diff --git a/hst-frontend/css/components.css b/hst-frontend/css/components.css new file mode 100644 index 0000000..4dd1d03 --- /dev/null +++ b/hst-frontend/css/components.css @@ -0,0 +1,103 @@ +/* === TOAST === */ +.toast { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: var(--accent); + color: #fff; + padding: 14px 28px; + border-radius: 10px; + font-size: 0.9em; + font-weight: 500; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 1000; + pointer-events: none; +} +.toast.show { opacity: 1; } + +/* === MODAL === */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.85); + display: none; + align-items: center; + justify-content: center; + z-index: 100; +} +.modal-overlay.open { display: flex; } +.modal { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 14px; + width: 90%; + max-width: 620px; + max-height: 80vh; + overflow: hidden; +} +.modal-header { + padding: 18px 22px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} +.modal-title { font-size: 1.15em; color: var(--text); font-weight: 600; } +.modal-close { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1.5em; + padding: 4px 8px; +} +.modal-close:hover { color: var(--text); } +.modal-body { padding: 22px; overflow-y: auto; max-height: calc(80vh - 65px); } +.api-item { margin-bottom: 18px; } +.api-endpoint { + font-family: monospace; + font-size: 0.9em; + color: var(--accent); + background: var(--bg-card); + padding: 12px 14px; + border-radius: 8px; +} +.api-desc { font-size: 0.85em; color: var(--text-muted); margin-top: 8px; } + +/* === EMPTY STATE === */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + gap: 16px; +} +.empty-state-icon { font-size: 4em; opacity: 0.4; } + +/* === LOADING === */ +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + gap: 14px; + font-size: 1em; +} +.loading::after { + content: ""; + width: 28px; + height: 28px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/hst-frontend/css/detail.css b/hst-frontend/css/detail.css new file mode 100644 index 0000000..fb3db96 --- /dev/null +++ b/hst-frontend/css/detail.css @@ -0,0 +1,99 @@ +/* === RIGHT PANEL (DETAIL) === */ +.right-panel { + width: 340px; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + overflow-y: auto; + display: none; + flex-shrink: 0; +} +.right-panel.open { display: block; } + +.detail-header { + position: relative; + width: 100%; + height: 220px; + background: linear-gradient(145deg, var(--bg-card) 0%, var(--bg) 100%); + overflow: hidden; +} +.detail-placeholder { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 5em; + font-weight: 700; + color: var(--accent); + opacity: 0.4; + text-transform: uppercase; +} +.detail-img { + width: 100%; + height: 100%; + object-fit: cover; + position: relative; + z-index: 1; +} +.detail-close { + position: absolute; + top: 12px; + right: 12px; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(0,0,0,0.7); + border: none; + color: #fff; + cursor: pointer; + font-size: 20px; + z-index: 5; + transition: background 0.15s ease; +} +.detail-close:hover { background: rgba(0,0,0,0.9); } + +.detail-body { padding: 20px; } +.detail-ref { + font-size: 1.2em; + color: var(--accent); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; +} +.detail-mrf { + font-size: 0.7em; + color: var(--text-muted); + margin-top: 8px; + font-family: monospace; + word-break: break-all; + cursor: pointer; + padding: 8px 10px; + background: var(--bg-card); + border-radius: 6px; + transition: color 0.15s ease; +} +.detail-mrf:hover { color: var(--accent); } +.detail-name { font-size: 1.3em; color: var(--text); margin-top: 16px; font-weight: 500; } +.detail-desc { font-size: 0.9em; color: var(--text-muted); margin-top: 12px; line-height: 1.7; } + +.detail-section { margin-top: 24px; } +.detail-section-title { + font-size: 0.75em; + color: var(--text-muted); + text-transform: uppercase; + margin-bottom: 12px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.tag-list { display: flex; flex-wrap: wrap; gap: 8px; } +.tag-chip { + padding: 7px 12px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.8em; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s ease; +} +.tag-chip:hover { border-color: var(--accent); color: var(--text); } diff --git a/hst-frontend/css/graph.css b/hst-frontend/css/graph.css new file mode 100644 index 0000000..b34ba2a --- /dev/null +++ b/hst-frontend/css/graph.css @@ -0,0 +1,76 @@ +/* === GRAPH VIEW === */ +.graph-view { + width: 100%; + height: 100%; + position: relative; + display: none; +} +#graph-svg { width: 100%; height: 100%; display: block; } +.node { cursor: pointer; } +.node text { fill: var(--text-muted); pointer-events: none; font-size: 11px; } +.node.selected circle { stroke: var(--accent); stroke-width: 4; } +.link { stroke-opacity: 0.5; } + +.graph-controls { position: absolute; top: 16px; right: 16px; display: flex; gap: 6px; } +.graph-sidebar { + position: absolute; + top: 16px; + left: 16px; + width: 210px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px; + font-size: 0.8em; + max-height: calc(100% - 32px); + overflow-y: auto; +} +.graph-sidebar h4 { + color: var(--text-muted); + margin: 12px 0 8px; + font-size: 0.85em; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.graph-sidebar h4:first-child { margin-top: 0; } + +.graph-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; } +.graph-stat { + background: var(--bg-card); + padding: 10px; + border-radius: 8px; + text-align: center; +} +.graph-stat-val { font-size: 1.4em; font-weight: 700; color: var(--accent); } +.graph-stat-label { font-size: 0.7em; color: var(--text-muted); margin-top: 3px; } + +.graph-filters { display: flex; flex-wrap: wrap; gap: 5px; } +.graph-filter { + padding: 5px 10px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 5px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + transition: all 0.15s ease; + font-size: 0.85em; +} +.graph-filter:hover { border-color: var(--accent); } +.graph-filter.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.graph-filter .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } + +.graph-slider { width: 100%; margin: 6px 0; accent-color: var(--accent); } +.graph-legend { + position: absolute; + bottom: 16px; + left: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 14px; + font-size: 0.75em; +} +.legend-item { display: flex; align-items: center; margin: 4px 0; color: var(--text-muted); } +.legend-color { width: 12px; height: 12px; border-radius: 50%; margin-right: 10px; flex-shrink: 0; } diff --git a/hst-frontend/css/grid.css b/hst-frontend/css/grid.css new file mode 100644 index 0000000..9df4bc0 --- /dev/null +++ b/hst-frontend/css/grid.css @@ -0,0 +1,102 @@ +/* === GRID VIEW === */ +.grid-view { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 16px; + padding: 20px; + overflow-y: auto; + height: 100%; +} + +.card { + width: var(--card-width); + flex-shrink: 0; + flex-grow: 0; + background: var(--bg-card); + border-radius: 10px; + border: 1px solid var(--border); + overflow: hidden; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; + position: relative; +} +.card:hover { + border-color: var(--accent); + transform: translateY(-3px); + box-shadow: 0 8px 20px rgba(0,0,0,0.3); +} +.card.selected { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(124, 138, 255, 0.4); +} + +.card-checkbox { + position: absolute; + top: 10px; + left: 10px; + width: 24px; + height: 24px; + border-radius: 6px; + background: rgba(0,0,0,0.7); + border: 2px solid var(--border); + display: none; + align-items: center; + justify-content: center; + z-index: 5; + transition: all 0.15s ease; +} +.card-checkbox.visible { display: flex; } +.card-checkbox.checked { background: var(--accent); border-color: var(--accent); } +.card-checkbox.checked::after { content: "\2713"; color: #fff; font-size: 14px; font-weight: bold; } + +.card-image { + width: var(--card-width); + height: var(--card-img-height); + background: linear-gradient(145deg, #1a1a24 0%, #0a0a0f 100%); + position: relative; + overflow: hidden; +} + +.card-placeholder { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5em; + font-weight: 700; + color: var(--accent); + opacity: 0.5; + text-transform: uppercase; + pointer-events: none; +} + +.card-img { + width: 100%; + height: 100%; + object-fit: cover; + position: relative; + z-index: 1; +} + +.card-body { padding: 12px; } +.card-ref { + font-size: 0.75em; + color: var(--accent); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.card-name { + font-size: 0.85em; + color: var(--text); + margin-top: 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; +} diff --git a/hst-frontend/css/main.css b/hst-frontend/css/main.css new file mode 100644 index 0000000..34c5787 --- /dev/null +++ b/hst-frontend/css/main.css @@ -0,0 +1,181 @@ +/* === RESET & VARIABLES === */ +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #0a0a0f; + --bg-secondary: #12121a; + --bg-card: #1a1a24; + --border: #2a2a3a; + --text: #e0e0e0; + --text-muted: #888; + --accent: #7c8aff; + --card-width: 176px; + --card-img-height: 176px; +} + +html, body { height: 100%; overflow: hidden; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: var(--bg-secondary); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; } +::-webkit-scrollbar-thumb:hover { background: #444; } + +/* === TOPBAR === */ +.topbar { + height: 50px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 16px; + gap: 12px; +} +.topbar-left { display: flex; align-items: center; gap: 10px; } +.topbar-center { flex: 1; display: flex; justify-content: center; } +.topbar-right { display: flex; align-items: center; gap: 10px; } +.logo { font-weight: 700; font-size: 1.2em; color: var(--accent); letter-spacing: 1px; } + +/* === BUTTONS === */ +.btn { + padding: 7px 14px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.8em; + font-weight: 500; + transition: all 0.15s ease; +} +.btn:hover { border-color: var(--accent); color: var(--text); } +.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-sm { padding: 5px 10px; font-size: 0.75em; } +.sel-count { font-size: 0.7em; margin-left: 4px; opacity: 0.8; } + +.search-box { + width: 300px; + padding: 9px 14px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 0.9em; +} +.search-box:focus { outline: none; border-color: var(--accent); } +.search-box::placeholder { color: var(--text-muted); } + +.base-selector { display: flex; gap: 2px; background: var(--bg-card); border-radius: 6px; padding: 3px; } +.base-btn { + padding: 6px 14px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.8em; + font-weight: 600; + transition: all 0.15s ease; +} +.base-btn:hover { color: var(--text); } +.base-btn.active { background: var(--accent); color: #fff; } + +/* === GROUPS BAR === */ +.groups-bar { + height: 44px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 16px; + gap: 8px; + overflow-x: auto; +} +.group-btn { + padding: 6px 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 20px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.75em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + transition: all 0.15s ease; +} +.group-btn:hover { border-color: var(--accent); color: var(--text); } +.group-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +/* === MAIN LAYOUT === */ +.main-layout { display: flex; height: calc(100vh - 94px); } + +/* === LEFT PANEL === */ +.left-panel { + width: 84px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + overflow-y: auto; + padding: 10px 6px; + flex-shrink: 0; +} +.lib-icon { + width: 68px; + height: 68px; + margin: 6px auto; + border-radius: 10px; + background: var(--bg-card); + border: 2px solid transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + transition: all 0.15s ease; + overflow: hidden; +} +.lib-icon:hover { border-color: var(--accent); } +.lib-icon.active { border-color: var(--accent); background: rgba(124, 138, 255, 0.15); } +.lib-icon img { width: 42px; height: 42px; object-fit: cover; border-radius: 6px; } +.lib-icon span { + font-size: 0.6em; + color: var(--text-muted); + margin-top: 4px; + text-align: center; + max-width: 62px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lib-icon.active span { color: var(--accent); } + +/* === CENTER PANEL === */ +.center-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } + +.view-tabs { + display: flex; + gap: 6px; + padding: 10px 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.view-tab { + padding: 7px 20px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + font-size: 0.85em; + font-weight: 500; + transition: all 0.15s ease; +} +.view-tab:hover { color: var(--text); background: var(--bg-card); } +.view-tab.active { background: var(--accent); color: #fff; } + +.view-container { flex: 1; overflow: hidden; position: relative; } diff --git a/hst-frontend/css/tree.css b/hst-frontend/css/tree.css new file mode 100644 index 0000000..60bdb07 --- /dev/null +++ b/hst-frontend/css/tree.css @@ -0,0 +1,55 @@ +/* === TREE VIEW === */ +.tree-view { + padding: 20px; + overflow-y: auto; + height: 100%; + display: none; +} +.tree-root { margin-bottom: 12px; } +.tree-node { margin-left: 28px; } +.tree-item { + display: flex; + align-items: center; + padding: 10px 12px; + cursor: pointer; + border-radius: 8px; + margin: 3px 0; + transition: background 0.15s ease; +} +.tree-item:hover { background: var(--bg-card); } +.tree-item.selected { background: rgba(124,138,255,0.15); } +.tree-toggle { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 1em; + font-weight: bold; + flex-shrink: 0; +} +.tree-checkbox { + width: 20px; + height: 20px; + border-radius: 5px; + background: var(--bg); + border: 2px solid var(--border); + margin-right: 12px; + display: none; + flex-shrink: 0; +} +.tree-checkbox.visible { display: block; } +.tree-checkbox.checked { background: var(--accent); border-color: var(--accent); } +.tree-img { + width: 32px; + height: 32px; + border-radius: 6px; + margin-right: 12px; + object-fit: cover; + flex-shrink: 0; + background: var(--bg-card); +} +.tree-name { font-size: 0.9em; } +.tree-children { display: none; } +.tree-children.open { display: block; } diff --git a/hst-frontend/index.html b/hst-frontend/index.html new file mode 100644 index 0000000..d8fb5bd --- /dev/null +++ b/hst-frontend/index.html @@ -0,0 +1,139 @@ + + + + DECK + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ +
+
+
+ + + + + +
+
+
+ + +
+ +
+ + +
+ +
+
ALL
+
+ + +
+
+ + + +
+
+
+
+
+
+ +
+ + + +
+
+
+
+
+ + +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ + +
+ + + + + + + + + + + + + + + + + diff --git a/hst-frontend/js/api.js b/hst-frontend/js/api.js new file mode 100644 index 0000000..642ae97 --- /dev/null +++ b/hst-frontend/js/api.js @@ -0,0 +1,81 @@ +// === API FUNCTIONS === + +async function fetchTags() { + try { + const r = await fetch(`${API}/${state.base}?order=ref.asc`); + state.tags = r.ok ? await r.json() : []; + } catch(e) { + state.tags = []; + } +} + +async function fetchGroups() { + try { + const r = await fetch(`${API}/api_groups`); + state.groups = r.ok ? await r.json() : []; + } catch(e) { + state.groups = []; + } +} + +async function fetchLibraries() { + try { + const r = await fetch(`${API}/api_library_list`); + state.libraries = r.ok ? await r.json() : []; + } catch(e) { + state.libraries = []; + } +} + +async function fetchGraphEdges() { + try { + const r = await fetch(`${API}/graph_hst`); + state.graphEdges = r.ok ? await r.json() : []; + } catch(e) { + state.graphEdges = []; + } +} + +async function fetchTreeEdges() { + try { + const r = await fetch(`${API}/tree_hst`); + state.treeEdges = r.ok ? await r.json() : []; + } catch(e) { + state.treeEdges = []; + } +} + +async function fetchLibraryMembers(mrf) { + try { + const r = await fetch(`${API}/library_hst?mrf_library=eq.${mrf}`); + return r.ok ? (await r.json()).map(d => d.mrf_tag) : []; + } catch(e) { + return []; + } +} + +async function fetchChildren(mrf) { + try { + const r = await fetch(`${API}/rpc/api_children`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({parent_mrf: mrf}) + }); + return r.ok ? await r.json() : []; + } catch(e) { + return []; + } +} + +async function fetchRelated(mrf) { + try { + const r = await fetch(`${API}/rpc/api_related`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({tag_mrf: mrf}) + }); + return r.ok ? await r.json() : []; + } catch(e) { + return []; + } +} diff --git a/hst-frontend/js/app.js b/hst-frontend/js/app.js new file mode 100644 index 0000000..c382abf --- /dev/null +++ b/hst-frontend/js/app.js @@ -0,0 +1,148 @@ +// === APPLICATION INIT === + +function parseHash() { + const h = window.location.hash.replace(/^#\/?/, "").replace(/\/?$/, "").split("/").filter(Boolean); + if (h[0] && ["hst","flg","itm","loc","ply"].includes(h[0].toLowerCase())) { + state.base = h[0].toLowerCase(); + } + if (h[1] && ["grid","tree","graph"].includes(h[1].toLowerCase())) { + state.view = h[1].toLowerCase(); + } +} + +function updateHash() { + const p = [state.base]; + if (state.view !== "grid") p.push(state.view); + window.location.hash = "/" + p.join("/") + "/"; +} + +async function init() { + parseHash(); + + // Update UI to match state + document.querySelectorAll(".base-btn").forEach(b => + b.classList.toggle("active", b.dataset.base === state.base) + ); + document.querySelectorAll(".view-tab").forEach(t => + t.classList.toggle("active", t.dataset.view === state.view) + ); + + // Show loading + document.getElementById("grid-view").innerHTML = '
Cargando
'; + + // Fetch initial data + await Promise.all([fetchTags(), fetchGroups(), fetchLibraries()]); + + // Render UI + renderGroups(); + renderLibraries(); + renderView(); +} + +// === EVENT BINDINGS === +document.addEventListener("DOMContentLoaded", () => { + // Base selector + document.querySelectorAll(".base-btn").forEach(b => b.onclick = async () => { + document.querySelectorAll(".base-btn").forEach(x => x.classList.remove("active")); + b.classList.add("active"); + state.base = b.dataset.base; + + // Reset state + state.group = "all"; + state.library = "all"; + state.libraryMembers.clear(); + state.search = ""; + state.graphEdges = []; + state.treeEdges = []; + clearSelection(); + closeDetail(); + document.getElementById("search").value = ""; + updateHash(); + + // Reload + document.getElementById("grid-view").innerHTML = '
Cargando
'; + await fetchTags(); + await fetchGroups(); + renderGroups(); + renderView(); + }); + + // View tabs + document.querySelectorAll(".view-tab").forEach(t => t.onclick = () => { + document.querySelectorAll(".view-tab").forEach(x => x.classList.remove("active")); + t.classList.add("active"); + state.view = t.dataset.view; + updateHash(); + closeDetail(); + renderView(); + }); + + // Search + let st; + document.getElementById("search").oninput = e => { + clearTimeout(st); + st = setTimeout(() => { + state.search = e.target.value; + renderView(); + }, 200); + }; + + // Language selector + document.getElementById("lang-select").addEventListener("change", function(e) { + state.lang = this.value; + renderView(); + if (state.selectedTag) showDetail(state.selectedTag.mrf); + }); + + // Selection mode + document.getElementById("btn-sel").onclick = () => { + state.selectionMode = !state.selectionMode; + document.getElementById("btn-sel").classList.toggle("active", state.selectionMode); + if (!state.selectionMode) { + state.selected.clear(); + updateSelCount(); + } + renderView(); + }; + + // Get selected + document.getElementById("btn-get").onclick = () => { + if (!state.selected.size) return toast("No hay seleccionados"); + navigator.clipboard.writeText([...state.selected].join("\n")) + .then(() => toast(`Copiados ${state.selected.size} mrfs`)); + }; + + // API modal + document.getElementById("btn-api").onclick = () => + document.getElementById("api-modal").classList.add("open"); + document.getElementById("api-modal-close").onclick = () => + document.getElementById("api-modal").classList.remove("open"); + document.getElementById("api-modal").onclick = e => { + if (e.target.id === "api-modal") e.target.classList.remove("open"); + }; + + // Hash change + window.onhashchange = () => { + parseHash(); + init(); + }; + + // Keyboard shortcuts + document.onkeydown = e => { + if (e.key === "Escape") { + closeDetail(); + document.getElementById("api-modal").classList.remove("open"); + if (state.selectionMode) { + clearSelection(); + renderView(); + } + } + if (e.key === "/" && document.activeElement.tagName !== "INPUT") { + e.preventDefault(); + document.getElementById("search").focus(); + } + }; + + // Start app + init(); +}); diff --git a/hst-frontend/js/config.js b/hst-frontend/js/config.js new file mode 100644 index 0000000..91d4028 --- /dev/null +++ b/hst-frontend/js/config.js @@ -0,0 +1,25 @@ +// === CONFIGURATION === + +const CATS = { + hst: {name: "Hashtags", color: "#7c8aff"}, + spe: {name: "Specs", color: "#FF9800"}, + vue: {name: "Values", color: "#00BCD4"}, + vsn: {name: "Visions", color: "#E91E63"}, + msn: {name: "Missions", color: "#9C27B0"}, + flg: {name: "Flags", color: "#4CAF50"} +}; + +const EDGE_COLORS = { + relation: "#8BC34A", + specialization: "#9C27B0", + mirror: "#607D8B", + dependency: "#2196F3", + sequence: "#4CAF50", + composition: "#FF9800", + hierarchy: "#E91E63", + library: "#00BCD4", + contextual: "#FFC107", + association: "#795548" +}; + +const API = "/api"; diff --git a/hst-frontend/js/helpers.js b/hst-frontend/js/helpers.js new file mode 100644 index 0000000..3868066 --- /dev/null +++ b/hst-frontend/js/helpers.js @@ -0,0 +1,77 @@ +// === HELPER FUNCTIONS === + +function getName(t) { + return state.lang === "en" + ? (t.name_en || t.name_es || t.ref) + : state.lang === "ch" + ? (t.name_ch || t.name_en || t.name_es || t.ref) + : (t.name_es || t.name_en || t.ref); +} + +function getImg(t) { + return t.img_thumb_url || t.img_url || ""; +} + +function getFullImg(t) { + return t.img_url || t.img_thumb_url || ""; +} + +function filterTags() { + let f = [...state.tags]; + + // Search filter + if (state.search) { + const q = state.search.toLowerCase(); + f = f.filter(t => + (t.ref||"").toLowerCase().includes(q) || + (t.name_es||"").toLowerCase().includes(q) || + (t.name_en||"").toLowerCase().includes(q) || + (t.mrf||"").toLowerCase().includes(q) + ); + } + + // Group filter + if (state.group !== "all") { + f = f.filter(t => t.set_hst === state.group); + } + + // Library filter + if (state.library !== "all" && state.libraryMembers.size) { + f = f.filter(t => state.libraryMembers.has(t.mrf)); + } + + return f; +} + +function toast(msg) { + const t = document.getElementById("toast"); + t.textContent = msg; + t.classList.add("show"); + setTimeout(() => t.classList.remove("show"), 2500); +} + +function copyMrf(mrf) { + navigator.clipboard.writeText(mrf).then(() => toast(`Copiado: ${mrf.slice(0,16)}...`)); +} + +function closeDetail() { + document.getElementById("right-panel").classList.remove("open"); + state.selectedTag = null; +} + +function toggleSel(mrf) { + state.selected.has(mrf) ? state.selected.delete(mrf) : state.selected.add(mrf); + updateSelCount(); + renderView(); +} + +function updateSelCount() { + document.getElementById("sel-count").textContent = state.selected.size ? `(${state.selected.size})` : ""; +} + +function clearSelection() { + state.selected.clear(); + state.selectionMode = false; + document.getElementById("btn-sel").classList.remove("active"); + updateSelCount(); +} diff --git a/hst-frontend/js/state.js b/hst-frontend/js/state.js new file mode 100644 index 0000000..4d23479 --- /dev/null +++ b/hst-frontend/js/state.js @@ -0,0 +1,40 @@ +// === APPLICATION STATE === + +const state = { + // Current selections + base: "hst", + lang: "es", + view: "grid", + search: "", + group: "all", + library: "all", + + // Library filter + libraryMembers: new Set(), + + // Selection mode + selectionMode: false, + selected: new Set(), + selectedTag: null, + + // Data + tags: [], + groups: [], + libraries: [], + graphEdges: [], + treeEdges: [], + + // Graph filters + graphFilters: { + cats: new Set(["hst"]), + edges: new Set(Object.keys(EDGE_COLORS)) + }, + + // Graph settings + graphSettings: { + nodeSize: 20, + linkDist: 80, + showImg: true, + showLbl: true + } +}; diff --git a/hst-frontend/js/ui.js b/hst-frontend/js/ui.js new file mode 100644 index 0000000..98f79bd --- /dev/null +++ b/hst-frontend/js/ui.js @@ -0,0 +1,68 @@ +// === UI RENDER FUNCTIONS === + +function renderGroups() { + const el = document.getElementById("groups-bar"); + + // Count tags per group + const gm = new Map(); + state.tags.forEach(t => { + if (t.set_hst) { + if (!gm.has(t.set_hst)) gm.set(t.set_hst, 0); + gm.set(t.set_hst, gm.get(t.set_hst) + 1); + } + }); + + const groups = [...gm.entries()].sort((a,b) => b[1] - a[1]); + + el.innerHTML = `` + + groups.slice(0, 20).map(([mrf, cnt]) => { + const info = state.groups.find(g => g.mrf === mrf); + const name = info ? (info.name_es || info.ref) : mrf.slice(0, 6); + return ``; + }).join(""); + + el.querySelectorAll(".group-btn").forEach(b => { + b.onclick = () => { + state.group = b.dataset.group; + renderGroups(); + renderView(); + }; + }); +} + +function renderLibraries() { + const el = document.getElementById("left-panel"); + + el.innerHTML = `
ALL
` + + state.libraries.map(lib => { + const icon = lib.img_thumb_url || lib.icon_url || ""; + const name = lib.name || lib.name_es || lib.ref || lib.mrf.slice(0, 6); + return `
+ ${icon ? `` : ""} + ${name.slice(0, 8)} +
`; + }).join(""); + + el.querySelectorAll(".lib-icon").forEach(i => { + i.onclick = async () => { + state.library = i.dataset.lib; + state.libraryMembers = state.library !== "all" + ? new Set(await fetchLibraryMembers(state.library)) + : new Set(); + renderLibraries(); + renderView(); + }; + }); +} + +function renderView() { + // Toggle view visibility + document.getElementById("grid-view").style.display = state.view === "grid" ? "flex" : "none"; + document.getElementById("tree-view").style.display = state.view === "tree" ? "block" : "none"; + document.getElementById("graph-view").style.display = state.view === "graph" ? "block" : "none"; + + // Render active view + if (state.view === "grid") renderGrid(); + else if (state.view === "tree") renderTree(); + else if (state.view === "graph") initGraph(); +} diff --git a/hst-frontend/js/views/detail.js b/hst-frontend/js/views/detail.js new file mode 100644 index 0000000..bc1de8e --- /dev/null +++ b/hst-frontend/js/views/detail.js @@ -0,0 +1,72 @@ +// === DETAIL PANEL === + +async function showDetail(mrf) { + const tag = state.tags.find(t => t.mrf === mrf); + if (!tag) return; + + state.selectedTag = tag; + document.getElementById("right-panel").classList.add("open"); + + const hdr = document.getElementById("detail-header"); + const img = getFullImg(tag); + const ref = (tag.ref || "").toUpperCase(); + + // Set placeholder + document.getElementById("detail-placeholder").textContent = ref.slice(0, 2); + + // Remove existing image + hdr.querySelector("img")?.remove(); + + // Add image if exists + if (img) { + const imgEl = document.createElement("img"); + imgEl.className = "detail-img"; + imgEl.src = img; + imgEl.alt = ref; + hdr.insertBefore(imgEl, hdr.firstChild); + } + + // Bind close + document.getElementById("detail-close").onclick = closeDetail; + + // Fill basic info + document.getElementById("detail-ref").textContent = ref; + document.getElementById("detail-mrf").textContent = tag.mrf || ""; + document.getElementById("detail-mrf").onclick = () => copyMrf(tag.mrf); + document.getElementById("detail-name").textContent = getName(tag); + document.getElementById("detail-desc").textContent = tag.txt || tag.alias || ""; + + // Fetch and render children + const children = await fetchChildren(mrf); + const chSec = document.getElementById("children-section"); + const chList = document.getElementById("children-list"); + + if (children.length) { + chSec.style.display = "block"; + chList.innerHTML = children.map(c => + `${c.ref || c.mrf.slice(0,8)}` + ).join(""); + chList.querySelectorAll(".tag-chip").forEach(ch => + ch.onclick = () => showDetail(ch.dataset.mrf) + ); + } else { + chSec.style.display = "none"; + } + + // Fetch and render related + const related = await fetchRelated(mrf); + const relSec = document.getElementById("related-section"); + const relList = document.getElementById("related-list"); + + if (related.length) { + relSec.style.display = "block"; + relList.innerHTML = related.map(r => + `${r.ref || r.mrf.slice(0,8)}` + ).join(""); + relList.querySelectorAll(".tag-chip").forEach(ch => + ch.onclick = () => showDetail(ch.dataset.mrf) + ); + } else { + relSec.style.display = "none"; + } +} diff --git a/hst-frontend/js/views/graph.js b/hst-frontend/js/views/graph.js new file mode 100644 index 0000000..051ee63 --- /dev/null +++ b/hst-frontend/js/views/graph.js @@ -0,0 +1,264 @@ +// === GRAPH VIEW === + +let gSvg, gG, gZoom, gSim; + +function renderGraphSidebar() { + const el = document.getElementById("graph-sidebar"); + const nc = filterTags().length; + const ec = state.graphEdges.length + state.treeEdges.length; + + el.innerHTML = ` +
+
+
${nc}
+
Nodos
+
+
+
${ec}
+
Edges
+
+
+

Categorias

+
+ ${Object.entries(CATS).map(([k,v]) => + `
+ ${v.name} +
` + ).join("")} +
+

Relaciones

+
+ ${Object.entries(EDGE_COLORS).map(([k,v]) => + `
+ ${k} +
` + ).join("")} +
+

Visualizacion

+
+ +
+
+ +
+
+
Nodo: ${state.graphSettings.nodeSize}px
+ +
+
+
Distancia: ${state.graphSettings.linkDist}px
+ +
`; + + // Bind category filters + el.querySelectorAll("[data-cat]").forEach(f => { + f.onclick = () => { + const c = f.dataset.cat; + state.graphFilters.cats.has(c) ? state.graphFilters.cats.delete(c) : state.graphFilters.cats.add(c); + initGraph(); + }; + }); + + // Bind edge filters + el.querySelectorAll("[data-edge]").forEach(f => { + f.onclick = () => { + const e = f.dataset.edge; + state.graphFilters.edges.has(e) ? state.graphFilters.edges.delete(e) : state.graphFilters.edges.add(e); + initGraph(); + }; + }); + + // Bind settings + document.getElementById("graph-show-img").onchange = e => { + state.graphSettings.showImg = e.target.checked; + updateGraphVisuals(); + }; + document.getElementById("graph-show-lbl").onchange = e => { + state.graphSettings.showLbl = e.target.checked; + updateGraphVisuals(); + }; + document.getElementById("graph-node-size").oninput = e => { + state.graphSettings.nodeSize = +e.target.value; + updateGraphVisuals(); + }; + document.getElementById("graph-link-dist").oninput = e => { + state.graphSettings.linkDist = +e.target.value; + if (gSim) { + gSim.force("link").distance(state.graphSettings.linkDist); + gSim.alpha(0.3).restart(); + } + }; +} + +function renderGraphLegend() { + document.getElementById("graph-legend").innerHTML = Object.entries(CATS) + .filter(([k]) => state.graphFilters.cats.has(k)) + .map(([k,v]) => `
${v.name}
`) + .join(""); +} + +function updateGraphVisuals() { + if (!gG) return; + const ns = state.graphSettings.nodeSize; + + gG.selectAll(".node circle").attr("r", ns); + gG.selectAll(".node image") + .attr("x", -ns+5) + .attr("y", -ns+5) + .attr("width", (ns-5)*2) + .attr("height", (ns-5)*2) + .style("display", state.graphSettings.showImg ? "block" : "none"); + gG.selectAll(".node text") + .attr("dx", ns+5) + .style("display", state.graphSettings.showLbl ? "block" : "none"); + + renderGraphSidebar(); +} + +async function initGraph() { + const container = document.getElementById("graph-view"); + const svg = document.getElementById("graph-svg"); + const w = container.clientWidth - 230; + const h = container.clientHeight; + + if (gSim) gSim.stop(); + + gSvg = d3.select(svg).attr("width", w + 230).attr("height", h); + gSvg.selectAll("*").remove(); + gG = gSvg.append("g").attr("transform", "translate(230, 0)"); + + gZoom = d3.zoom() + .scaleExtent([0.05, 4]) + .on("zoom", e => gG.attr("transform", `translate(230, 0) ${e.transform}`)); + gSvg.call(gZoom); + + renderGraphSidebar(); + renderGraphLegend(); + + // Fetch data if needed + if (!state.graphEdges.length) await fetchGraphEdges(); + if (!state.treeEdges.length) await fetchTreeEdges(); + + // Build nodes + const filtered = filterTags(); + const nodes = filtered.map(t => { + const grupo = t.set_hst || "hst"; + const groupInfo = state.groups.find(g => g.mrf === grupo); + const cat = groupInfo?.ref || "hst"; + return { id: t.mrf, ref: t.ref || "", name: getName(t), img: getImg(t), cat }; + }).filter(n => state.graphFilters.cats.has(n.cat) || state.graphFilters.cats.has("hst")); + + if (!nodes.length) { + gG.append("text") + .attr("x", w/2) + .attr("y", h/2) + .attr("text-anchor", "middle") + .attr("fill", "#666") + .text("Sin datos - activa categorias"); + return; + } + + // Build edges + const nodeIds = new Set(nodes.map(n => n.id)); + const edges = []; + + state.graphEdges.forEach(e => { + const t = e.edge_type || "relation"; + if (state.graphFilters.edges.has(t) && nodeIds.has(e.mrf_a) && nodeIds.has(e.mrf_b)) { + edges.push({source: e.mrf_a, target: e.mrf_b, type: t, weight: e.weight || 0.5}); + } + }); + + if (state.graphFilters.edges.has("hierarchy")) { + state.treeEdges.forEach(e => { + if (nodeIds.has(e.mrf_parent) && nodeIds.has(e.mrf_child)) { + edges.push({source: e.mrf_parent, target: e.mrf_child, type: "hierarchy", weight: 0.8}); + } + }); + } + + // Create simulation + const ns = state.graphSettings.nodeSize; + gSim = d3.forceSimulation(nodes) + .force("link", d3.forceLink(edges).id(d => d.id).distance(state.graphSettings.linkDist)) + .force("charge", d3.forceManyBody().strength(-150)) + .force("center", d3.forceCenter(w/2, h/2)) + .force("collision", d3.forceCollide(ns + 4)); + + // Draw links + const link = gG.append("g") + .selectAll("line") + .data(edges) + .enter() + .append("line") + .attr("stroke", d => EDGE_COLORS[d.type] || "#333") + .attr("stroke-width", d => Math.max(1.5, d.weight * 3)) + .attr("class", "link"); + + // Draw nodes + const node = gG.append("g") + .selectAll("g") + .data(nodes) + .enter() + .append("g") + .attr("class", d => `node ${state.selected.has(d.id) ? "selected" : ""}`) + .call(d3.drag() + .on("start", (e,d) => { + if (!e.active) gSim.alphaTarget(0.3).restart(); + d.fx = d.x; d.fy = d.y; + }) + .on("drag", (e,d) => { d.fx = e.x; d.fy = e.y; }) + .on("end", (e,d) => { + if (!e.active) gSim.alphaTarget(0); + d.fx = null; d.fy = null; + }) + ); + + node.append("circle") + .attr("r", ns) + .attr("fill", d => CATS[d.cat]?.color || "#7c8aff") + .attr("stroke", d => state.selected.has(d.id) ? "var(--accent)" : "#1a1a24") + .attr("stroke-width", d => state.selected.has(d.id) ? 4 : 2); + + node.filter(d => d.img && state.graphSettings.showImg) + .append("image") + .attr("href", d => d.img) + .attr("x", -ns+5) + .attr("y", -ns+5) + .attr("width", (ns-5)*2) + .attr("height", (ns-5)*2) + .attr("clip-path", "circle(50%)"); + + node.append("text") + .attr("dx", ns+5) + .attr("dy", 4) + .text(d => d.ref) + .style("display", state.graphSettings.showLbl ? "block" : "none"); + + node.on("click", (e,d) => state.selectionMode ? toggleSel(d.id) : showDetail(d.id)); + + // Tick handler + gSim.on("tick", () => { + link + .attr("x1", d => d.source.x) + .attr("y1", d => d.source.y) + .attr("x2", d => d.target.x) + .attr("y2", d => d.target.y); + node.attr("transform", d => `translate(${d.x},${d.y})`); + }); + + // Controls + document.getElementById("graph-fit").onclick = () => { + const b = gG.node().getBBox(); + if (b.width) { + const s = Math.min((w-100)/b.width, (h-100)/b.height, 2); + gSvg.transition().call(gZoom.transform, + d3.zoomIdentity + .translate(w/2-(b.x+b.width/2)*s+230, h/2-(b.y+b.height/2)*s) + .scale(s) + ); + } + }; + document.getElementById("graph-zin").onclick = () => gSvg.transition().call(gZoom.scaleBy, 1.5); + document.getElementById("graph-zout").onclick = () => gSvg.transition().call(gZoom.scaleBy, 0.67); +} diff --git a/hst-frontend/js/views/grid.js b/hst-frontend/js/views/grid.js new file mode 100644 index 0000000..e8dd407 --- /dev/null +++ b/hst-frontend/js/views/grid.js @@ -0,0 +1,33 @@ +// === GRID VIEW === + +function renderGrid() { + const el = document.getElementById("grid-view"); + const filtered = filterTags(); + + if (!filtered.length) { + el.innerHTML = '
:/
No se encontraron tags
'; + return; + } + + el.innerHTML = filtered.map(tag => { + const img = getImg(tag); + const ref = (tag.ref || "").toUpperCase(); + const ph = ref.slice(0, 2); + const sel = state.selected.has(tag.mrf); + + return `
+
+
+ ${img ? `${ref}` : `
${ph}
`} +
+
+
${ref}
+
${getName(tag)}
+
+
`; + }).join(""); + + el.querySelectorAll(".card").forEach(c => { + c.onclick = () => state.selectionMode ? toggleSel(c.dataset.mrf) : showDetail(c.dataset.mrf); + }); +} diff --git a/hst-frontend/js/views/tree.js b/hst-frontend/js/views/tree.js new file mode 100644 index 0000000..b9104a2 --- /dev/null +++ b/hst-frontend/js/views/tree.js @@ -0,0 +1,64 @@ +// === TREE VIEW === + +function renderTree() { + const el = document.getElementById("tree-view"); + const filtered = filterTags(); + + if (!filtered.length) { + el.innerHTML = '
Sin datos
'; + return; + } + + // Group tags by set_hst + const groups = new Map(); + filtered.forEach(t => { + const g = t.set_hst || "other"; + if (!groups.has(g)) groups.set(g, []); + groups.get(g).push(t); + }); + + el.innerHTML = [...groups.entries()].map(([gid, tags]) => { + const info = state.groups.find(g => g.mrf === gid); + const name = info ? (info.name_es || info.ref) : gid === "other" ? "Sin grupo" : gid.slice(0, 10); + + return `
+
+ + + ${name} (${tags.length}) +
+
+ ${tags.map(t => { + const sel = state.selected.has(t.mrf); + const img = getImg(t); + return `
+
+ + + ${img ? `` : ""} + ${t.ref} - ${getName(t)} +
+
`; + }).join("")} +
+
`; + }).join(""); + + // Bind expand/collapse + el.querySelectorAll(".tree-item[data-expand]").forEach(i => { + i.onclick = () => { + const ch = document.getElementById(`tree-${i.dataset.expand}`); + if (ch) { + ch.classList.toggle("open"); + i.querySelector(".tree-toggle").textContent = ch.classList.contains("open") ? "-" : "+"; + } + }; + }); + + // Bind tag click + el.querySelectorAll(".tree-item[data-mrf]").forEach(i => { + i.onclick = e => { + e.stopPropagation(); + state.selectionMode ? toggleSel(i.dataset.mrf) : showDetail(i.dataset.mrf); + }; + }); +}