Add pending apps and frontend components

- apps/captain-mobile: Mobile API service
- apps/flow-ui: Flow UI application
- apps/mindlink: Mindlink application
- apps/storage: Storage API and workers
- apps/tzzr-cli: TZZR CLI tool
- deck-frontend/backups: Historical TypeScript versions
- hst-frontend: Standalone HST frontend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ARCHITECT
2026-01-16 18:26:59 +00:00
parent 17506aaee2
commit 9b244138b5
177 changed files with 15063 additions and 0 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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);

1
apps/flow-ui Submodule

Submodule apps/flow-ui added at f0c09b10ad

1
apps/mindlink Submodule

Submodule apps/mindlink added at 40c0944cf7

109
apps/storage/migrate_atc.py Normal file
View File

@@ -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())

View File

@@ -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

View File

@@ -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

445
apps/storage/storage_api.py Normal file
View File

@@ -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)

View File

@@ -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 <command> [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 <hash> <size> <mime> <path>")
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())

View File

@@ -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())

1
apps/tzzr-cli Submodule

Submodule apps/tzzr-cli added at 0327df5277

View File

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="es">
<head>
<title>DECK</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div class="app">
<!-- TOPBAR -->
<div class="topbar">
<div class="topbar-left">
<span class="logo">DECK</span>
<select id="lang-select" class="btn btn-sm">
<option value="es">ES</option>
<option value="en">EN</option>
<option value="ch">CH</option>
</select>
<button class="btn btn-sm" id="btn-api">API</button>
</div>
<div class="topbar-center">
<!-- Taxonomía -->
<div class="base-buttons">
<button class="base-btn active" data-base="hst">HST</button>
<button class="base-btn" data-base="flg">FLG</button>
<button class="base-btn" data-base="itm">ITM</button>
<button class="base-btn" data-base="loc">LOC</button>
<button class="base-btn" data-base="ply">PLY</button>
</div>
<!-- Maestros -->
<div class="base-buttons">
<button class="base-btn" data-base="mst">MST</button>
<button class="base-btn" data-base="bck">BCK</button>
</div>
<!-- Registro -->
<div class="base-buttons">
<button class="base-btn" data-base="mth">MTH</button>
<button class="base-btn" data-base="atc">ATC</button>
</div>
<!-- Comunicación -->
<div class="base-buttons">
<button class="base-btn" data-base="mail">MAIL</button>
<button class="base-btn" data-base="chat">CHAT</button>
</div>
<!-- Servicios -->
<div class="base-buttons">
<button class="base-btn" data-base="key">KEY</button>
<button class="base-btn" data-base="mindlink">MIND</button>
</div>
</div>
<div class="topbar-right">
<div class="search-box">
<input type="text" id="search" class="search-input" placeholder="Buscar...">
</div>
</div>
</div>
<!-- VIEW BAR -->
<div class="view-bar">
<div class="sel-group">
<button class="sel-btn" id="btn-sel">SEL</button>
<button class="sel-btn" id="btn-get">GET</button>
<span id="sel-count"></span>
</div>
<div class="view-tabs">
<button class="view-tab active" data-view="grid">Grid</button>
<button class="view-tab" data-view="tree">Tree</button>
<button class="view-tab" data-view="graph">Graph</button>
</div>
<div class="view-bar-spacer"></div>
</div>
<!-- GROUPS BAR -->
<div id="groups-bar" class="groups-bar"></div>
<!-- MAIN LAYOUT -->
<div class="main-layout">
<!-- LEFT PANEL - Libraries -->
<div class="left-panel" id="left-panel">
<div class="lib-icon active" data-lib="all" title="Todos"><span>ALL</span></div>
</div>
<!-- CENTER PANEL -->
<div class="center-panel">
<div id="content-area" class="content-area grid-view">
<div class="loading">Cargando...</div>
</div>
</div>
<!-- RIGHT PANEL - Detail -->
<div id="detail-panel" class="detail-panel"></div>
</div>
<!-- API MODAL -->
<div id="api-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>API Reference</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="api-item">
<div class="api-endpoint">GET /api/{base}</div>
<div class="api-desc">Lista tags (base: hst, flg, itm, loc, ply)</div>
</div>
<div class="api-item">
<div class="api-endpoint">POST /api/rpc/api_children</div>
<div class="api-desc">Obtener hijos de un tag</div>
</div>
<div class="api-item">
<div class="api-endpoint">POST /api/rpc/api_related</div>
<div class="api-desc">Obtener tags relacionados</div>
</div>
<div class="api-item">
<div class="api-endpoint">GET /api/graph_hst</div>
<div class="api-desc">Relaciones del grafo</div>
</div>
<div class="api-item">
<div class="api-endpoint">GET /api/tree_hst</div>
<div class="api-desc">Jerarquias</div>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
import { API_BASE } from '@/config/index.ts';
interface FetchOptions {
method?: 'GET' | 'POST';
body?: Record<string, unknown>;
schema?: string; // PostgREST Accept-Profile header
}
export async function apiClient<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<T> {
const { method = 'GET', body, schema } = options;
const headers: Record<string, string> = {};
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<T>(
endpoint: string,
options: FetchOptions = {},
fallback: T
): Promise<T> {
try {
return await apiClient<T>(endpoint, options);
} catch {
console.error(`API call failed: ${endpoint}`);
return fallback;
}
}

View File

@@ -0,0 +1,8 @@
import { apiClientSafe } from './client.ts';
import type { GraphEdge, TreeEdge } from '@/types/index.ts';
export const fetchGraphEdges = (): Promise<GraphEdge[]> =>
apiClientSafe<GraphEdge[]>('/graph_hst', {}, []);
export const fetchTreeEdges = (): Promise<TreeEdge[]> =>
apiClientSafe<TreeEdge[]>('/tree_hst', {}, []);

View File

@@ -0,0 +1,5 @@
import { apiClientSafe } from './client.ts';
import type { Group } from '@/types/index.ts';
export const fetchGroups = (): Promise<Group[]> =>
apiClientSafe<Group[]>('/api_groups', {}, []);

View File

@@ -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';

View File

@@ -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<Library[]> => {
// 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<Library[]>(`/api_library_list_${base}`, {}, []);
};
export const fetchLibraryMembers = async (mrf: string, base: BaseType): Promise<string[]> => {
if (!LIBRARY_BASES.has(base)) {
return [];
}
const data = await apiClientSafe<Array<{ mrf_tag: string }>>(
`/library_${base}?mrf_library=eq.${mrf}`,
{},
[]
);
return data.map(d => d.mrf_tag);
};

View File

@@ -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<Tag[]> => {
const { schema, table } = getSchemaAndTable(base);
return apiClientSafe<Tag[]>(
`/${table}?order=ref.asc`,
schema ? { schema } : {},
[]
);
};
// Fetch HST tags for group name resolution (set_hst points to hst tags)
export const fetchHstTags = (): Promise<Tag[]> =>
apiClientSafe<Tag[]>('/hst?select=mrf,ref,alias,name_es,name_en,name_ch', {}, []);
export const fetchChildren = (mrf: string): Promise<ChildTag[]> =>
apiClientSafe<ChildTag[]>('/rpc/api_children', {
method: 'POST',
body: { parent_mrf: mrf }
}, []);
export const fetchRelated = (mrf: string): Promise<RelatedTag[]> =>
apiClientSafe<RelatedTag[]>('/rpc/api_related', {
method: 'POST',
body: { tag_mrf: mrf }
}, []);

View File

@@ -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<CardProps> {
protected template(): string {
const { tag, lang, selected, selectionMode } = this.props;
const img = getImg(tag);
const name = getName(tag, lang);
return `
<div class="card ${selected ? 'selected' : ''}" data-mrf="${tag.mrf}">
${selectionMode ? `
<input type="checkbox" class="card-checkbox" ${selected ? 'checked' : ''}>
` : ''}
${img
? `<img class="card-img" src="${img}" alt="${tag.ref}" loading="lazy">`
: `<div class="card-placeholder">${tag.ref?.slice(0, 2) || 'T'}</div>`
}
<div class="card-name">${name}</div>
</div>
`;
}
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);
}
});
}
}

View File

@@ -0,0 +1,42 @@
export abstract class Component<P extends object = object> {
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<P>): 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;
}
}

View File

@@ -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<ModalProps> {
protected template(): string {
const { title, content, isOpen } = this.props;
return `
<div class="modal ${isOpen ? 'open' : ''}">
<div class="modal-content">
<div class="modal-header">
<h3>${title}</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
${content}
</div>
</div>
</div>
`;
}
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');
}
}

View File

@@ -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<TagChipProps> {
protected template(): string {
const { mrf, label, title } = this.props;
return `
<span class="tag-chip" data-mrf="${mrf}" title="${title || ''}">
${label}
</span>
`;
}
protected bindEvents(): void {
this.element.addEventListener('click', () => {
this.props.onClick(this.props.mrf);
});
}
}

View File

@@ -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';

View File

@@ -0,0 +1 @@
export const API_BASE = '/api';

View File

@@ -0,0 +1,15 @@
import type { CategoryKey } from '@/types/index.ts';
export interface CategoryConfig {
name: string;
color: string;
}
export const CATS: Record<CategoryKey, CategoryConfig> = {
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' }
};

View File

@@ -0,0 +1,14 @@
import type { EdgeType } from '@/types/index.ts';
export const EDGE_COLORS: Record<EdgeType, string> = {
relation: '#8BC34A',
specialization: '#9C27B0',
mirror: '#607D8B',
dependency: '#2196F3',
sequence: '#4CAF50',
composition: '#FF9800',
hierarchy: '#E91E63',
library: '#00BCD4',
contextual: '#FFC107',
association: '#795548'
};

View File

@@ -0,0 +1,3 @@
export { CATS, type CategoryConfig } from './categories.ts';
export { EDGE_COLORS } from './edges.ts';
export { API_BASE } from './api.ts';

View File

@@ -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<void> {
this.router.parseHash();
await this.init();
this.bindEvents();
}
private async init(): Promise<void> {
const contentArea = $('#content-area');
const detailPanelEl = $('#detail-panel');
if (!contentArea || !detailPanelEl) return;
// Update UI
this.updateBaseButtons();
this.updateViewTabs();
// Show loading
contentArea.innerHTML = '<div class="loading">Cargando...</div>';
// 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<string, number>();
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 = `
<button class="group-btn ${state.group === 'all' ? 'active' : ''}" data-group="all">
Todos (${state.tags.length})
</button>
${sorted.map(([groupMrf, count]) => {
const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap);
return `
<button class="group-btn ${state.group === groupMrf ? 'active' : ''}" data-group="${groupMrf}">
${groupName} (${count})
</button>
`;
}).join('')}
`;
delegateEvent<MouseEvent>(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 = `
<div class="lib-icon ${state.library === 'all' ? 'active' : ''}" data-lib="all" title="Todos">
<span>ALL</span>
</div>
${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 `
<div class="lib-icon ${state.library === lib.mrf ? 'active' : ''}" data-lib="${lib.mrf}" title="${name}">
${icon ? `<img src="${icon}" alt="">` : ''}
<span>${name.slice(0, 8)}</span>
</div>
`;
}).join('')}
`;
delegateEvent<MouseEvent>(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 = `
<div class="graph-options">
<!-- Stats -->
<div class="graph-section">
<div class="graph-stat">
<span>Nodos</span>
<span class="graph-stat-value">${nodeCount}</span>
</div>
<div class="graph-stat">
<span>Edges</span>
<span class="graph-stat-value">${edgeCount}</span>
</div>
</div>
<!-- Categories -->
<div class="graph-section">
<div class="graph-section-title">Categorias</div>
${Object.entries(CATS).map(([key, config]) => `
<label class="graph-checkbox">
<input type="checkbox" data-cat="${key}" ${graphFilters.cats.has(key as CategoryKey) ? 'checked' : ''}>
<span class="color-dot" style="background: ${config.color}"></span>
${config.name}
</label>
`).join('')}
</div>
<!-- Edge Types -->
<div class="graph-section">
<div class="graph-section-title">Relaciones</div>
${Object.entries(EDGE_COLORS).map(([key, color]) => `
<label class="graph-checkbox">
<input type="checkbox" data-edge="${key}" ${graphFilters.edges.has(key as EdgeType) ? 'checked' : ''}>
<span class="color-dot" style="background: ${color}"></span>
${key}
</label>
`).join('')}
</div>
<!-- Visualization -->
<div class="graph-section">
<div class="graph-section-title">Visualizacion</div>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-img" ${graphSettings.showImg ? 'checked' : ''}>
Imagenes
</label>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-lbl" ${graphSettings.showLbl ? 'checked' : ''}>
Etiquetas
</label>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Nodo</span>
<span class="graph-slider-value" id="node-size-val">${graphSettings.nodeSize}px</span>
</div>
<input type="range" id="graph-node-size" min="10" max="60" value="${graphSettings.nodeSize}">
</div>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Distancia</span>
<span class="graph-slider-value" id="link-dist-val">${graphSettings.linkDist}px</span>
</div>
<input type="range" id="graph-link-dist" min="30" max="200" value="${graphSettings.linkDist}">
</div>
</div>
</div>
`;
// Bind events
this.bindGraphOptionEvents(container);
}
private bindGraphOptionEvents(container: HTMLElement): void {
// Category checkboxes
container.querySelectorAll<HTMLInputElement>('[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<HTMLInputElement>('[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<HTMLInputElement>('#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<HTMLInputElement>('#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<HTMLInputElement>('#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<HTMLInputElement>('#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<MouseEvent>(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<MouseEvent>(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();
});

View File

@@ -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<BaseType, BaseConfig> = {
// ═══════════════════════════════════════════════════════════════
// 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<ModuleCategory, BaseConfig[]> => {
const result: Record<ModuleCategory, BaseConfig[]> = {
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';
};

View File

@@ -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<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">💬</div>
<div class="module-disabled-title">Context Manager</div>
<div class="module-disabled-text">Chat con IA - Próximamente</div>
</div>
`;
}
}
export default ContextModule;

View File

@@ -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<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">🔑</div>
<div class="module-disabled-title">Keys</div>
<div class="module-disabled-text">Gestión de claves - Próximamente</div>
</div>
`;
}
}
export default KeyModule;

View File

@@ -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<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">📧</div>
<div class="module-disabled-title">Mail Assistant</div>
<div class="module-disabled-text">Interfaz de chat con IA - Próximamente</div>
</div>
`;
}
}
export default MailModule;

View File

@@ -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<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">🔗</div>
<div class="module-disabled-title">MindLink</div>
<div class="module-disabled-text">Gestión de hipervínculos - Próximamente</div>
</div>
`;
}
}
export default MindlinkModule;

View File

@@ -0,0 +1,4 @@
export { MailModule } from './MailModule.ts';
export { ContextModule } from './ContextModule.ts';
export { KeyModule } from './KeyModule.ts';
export { MindlinkModule } from './MindlinkModule.ts';

View File

@@ -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';

View File

@@ -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<AppState>;
private currentModule: BaseModule | null = null;
private currentBase: BaseType | null = null;
constructor(store: Store<AppState>) {
this.store = store;
}
/**
* Cargar módulo para una base
*/
async load(base: BaseType, targets: LoaderTargets): Promise<void> {
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 = '<div class="loading">Cargando...</div>';
// 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 = `
<div class="module-disabled">
<div class="module-disabled-icon">🚧</div>
<div class="module-disabled-title">${moduleName}</div>
<div class="module-disabled-text">Próximamente</div>
</div>
`;
}
private showErrorMessage(container: HTMLElement, message: string): void {
container.innerHTML = `
<div class="module-error">
<div class="module-error-icon">⚠️</div>
<div class="module-error-text">${message}</div>
</div>
`;
}
}
export default ModuleLoader;

View File

@@ -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<ModuleState>;
// 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<AppState>;
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<void>;
abstract unmount(): void;
abstract render(): void;
// Override para carga de datos específica
async loadData(): Promise<void> {
// 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<AppState> {
return this.ctx.store.getState();
}
protected setState(partial: Partial<AppState>): 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<BaseType, BaseConfig>, 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<BaseType, BaseConfig>
): Record<ModuleCategory, BaseConfig[]> => {
const result: Record<ModuleCategory, BaseConfig[]> = {
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<BaseType, BaseConfig>
): BaseConfig[] => {
return Object.values(configs).filter(c => c.enabled !== false);
};

View File

@@ -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<void> {
// Show loading
this.ctx.container.innerHTML = '<div class="loading">Cargando...</div>';
// 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<void> {
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 = '<div class="sidebar-empty">Sin bibliotecas</div>';
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 = `
<div class="lib-icon ${state.library === 'all' ? 'active' : ''}" data-lib="all" title="Todos">
<span>ALL</span>
</div>
${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 `
<div class="lib-icon ${state.library === lib.mrf ? 'active' : ''}" data-lib="${lib.mrf}" title="${name}">
${icon ? `<img src="${icon}" alt="">` : ''}
<span>${name.slice(0, 8)}</span>
</div>
`;
}).join('')}
`;
// Bind library clicks
delegateEvent<MouseEvent>(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<string, number>();
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 = `
<button class="group-btn ${state.group === 'all' ? 'active' : ''}" data-group="all">
Todos (${state.tags.length})
</button>
${sorted.map(([groupMrf, count]) => {
const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap);
return `
<button class="group-btn ${state.group === groupMrf ? 'active' : ''}" data-group="${groupMrf}">
${groupName} (${count})
</button>
`;
}).join('')}
`;
// Bind group clicks
delegateEvent<MouseEvent>(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 = `
<div class="graph-options">
<div class="graph-section">
<div class="graph-stat">
<span>Nodos</span>
<span class="graph-stat-value">${tags.length}</span>
</div>
<div class="graph-stat">
<span>Edges</span>
<span class="graph-stat-value">${graphEdges.length}</span>
</div>
</div>
<div class="graph-section">
<div class="graph-section-title">Visualización</div>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-img" ${graphSettings.showImg ? 'checked' : ''}>
Imágenes
</label>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-lbl" ${graphSettings.showLbl ? 'checked' : ''}>
Etiquetas
</label>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Nodo</span>
<span class="graph-slider-value" id="node-size-val">${graphSettings.nodeSize}px</span>
</div>
<input type="range" id="graph-node-size" min="10" max="60" value="${graphSettings.nodeSize}">
</div>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Distancia</span>
<span class="graph-slider-value" id="link-dist-val">${graphSettings.linkDist}px</span>
</div>
<input type="range" id="graph-link-dist" min="30" max="200" value="${graphSettings.linkDist}">
</div>
</div>
</div>
`;
this.bindGraphOptionEvents(container);
}
private bindGraphOptionEvents(container: HTMLElement): void {
// Show images checkbox
const showImgCb = container.querySelector<HTMLInputElement>('#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<HTMLInputElement>('#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<HTMLInputElement>('#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<HTMLInputElement>('#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;

View File

@@ -0,0 +1 @@
export { StandardModule, default } from './StandardModule.ts';

View File

@@ -0,0 +1 @@
export { Router } from './router.ts';

View File

@@ -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<AppState>;
private onNavigate: () => void;
constructor(store: Store<AppState>, 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();
}
}

View File

@@ -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';

View File

@@ -0,0 +1,27 @@
type Listener<T> = (state: T, prevState: T) => void;
export interface Store<T extends object> {
getState: () => Readonly<T>;
setState: (partial: Partial<T>) => void;
subscribe: (listener: Listener<T>) => () => void;
}
export function createStore<T extends object>(initialState: T): Store<T> {
let state = { ...initialState };
const listeners = new Set<Listener<T>>();
return {
getState: (): Readonly<T> => state,
setState: (partial: Partial<T>): void => {
const prevState = state;
state = { ...state, ...partial };
listeners.forEach(fn => fn(state, prevState));
},
subscribe: (listener: Listener<T>): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
}
};
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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<CategoryKey>;
edges: Set<EdgeType>;
}
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<string>;
// Selection
selectionMode: boolean;
selected: Set<string>;
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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,14 @@
import { toast } from './toast.ts';
export async function copyToClipboard(text: string, message?: string): Promise<void> {
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)}...`);
}

View File

@@ -0,0 +1,44 @@
export const $ = <T extends HTMLElement>(
selector: string,
parent: ParentNode = document
): T | null => parent.querySelector<T>(selector);
export const $$ = <T extends HTMLElement>(
selector: string,
parent: ParentNode = document
): T[] => Array.from(parent.querySelectorAll<T>(selector));
export function createElement<K extends keyof HTMLElementTagNameMap>(
tag: K,
attrs?: Record<string, string>,
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<T extends Event>(
container: HTMLElement,
selector: string,
eventType: string,
handler: (event: T, target: HTMLElement) => void
): void {
container.addEventListener(eventType, (event) => {
const target = (event.target as HTMLElement).closest<HTMLElement>(selector);
if (target && container.contains(target)) {
handler(event as T, target);
}
});
}

View File

@@ -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<string>;
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;
});
}

View File

@@ -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<string, string> {
const map = new Map<string, string>();
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, string>): 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);
}

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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<AppState>
) {
super(container, store);
this.panelEl = container;
}
async showDetail(mrf: string): Promise<void> {
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<void> {
const state = this.getState();
const img = getFullImg(tag);
const name = getName(tag, state.lang);
this.panelEl.innerHTML = `
<div class="detail-header">
${img
? `<img class="detail-img" src="${img}" alt="${tag.ref}">`
: `<div class="detail-placeholder">${tag.ref?.slice(0, 2) || 'T'}</div>`
}
<button class="detail-close">&times;</button>
</div>
<div class="detail-body">
<div class="detail-ref">${tag.ref || ''}</div>
<div class="detail-mrf" data-mrf="${tag.mrf}">${tag.mrf}</div>
<div class="detail-name">${name}</div>
<div class="detail-desc">${tag.txt || tag.alias || ''}</div>
<div id="children-section" class="detail-section" style="display:none">
<h4>Hijos</h4>
<div id="children-list" class="chip-list"></div>
</div>
<div id="related-section" class="detail-section" style="display:none">
<h4>Relacionados</h4>
<div id="related-list" class="chip-list"></div>
</div>
</div>
`;
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<MouseEvent>(this.panelEl, '.tag-chip', 'click', (_, target) => {
const mrf = target.dataset.mrf;
if (mrf) this.showDetail(mrf);
});
}
private async loadRelations(mrf: string): Promise<void> {
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 `<span class="tag-chip" data-mrf="${c.mrf}">${label}</span>`;
}).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 `<span class="tag-chip" data-mrf="${r.mrf}" title="${r.edge_type}">${label}</span>`;
}).join('');
}
}
}

View File

@@ -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<AppState>,
showDetail: (mrf: string) => void
) {
super(container, store);
this.showDetail = showDetail;
}
async mount(): Promise<void> {
this.container.innerHTML = '<div class="loading">Cargando grafo...</div>';
// 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<string, GraphNode>();
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 = '<div class="empty">Sin nodos para mostrar</div>';
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();
}
}

View File

@@ -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<AppState>,
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 = '<div class="empty">Sin resultados</div>';
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 `
<div class="card ${isSelected ? 'selected' : ''}" data-mrf="${tag.mrf}">
${state.selectionMode ? `
<input type="checkbox" class="card-checkbox" ${isSelected ? 'checked' : ''}>
` : ''}
${img
? `<img class="card-img" src="${img}" alt="${tag.ref}" loading="lazy">`
: `<div class="card-placeholder">${tag.ref?.slice(0, 2) || 'T'}</div>`
}
<div class="card-name">${name}</div>
</div>
`;
}).join('');
this.bindEvents();
}
private bindEvents(): void {
const state = this.getState();
delegateEvent<MouseEvent>(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);
}
});
}
}

View File

@@ -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<string> = new Set();
constructor(
container: HTMLElement,
store: Store<AppState>,
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<string, Tag[]>();
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 = '<div class="empty">Sin resultados</div>';
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 `
<div class="tree-group">
<div class="tree-header" data-group="${groupMrf}">
<span class="tree-toggle">${isExpanded ? '' : '+'}</span>
<span class="tree-group-name">${groupName}</span>
<span class="tree-count">${tags.length}</span>
</div>
<div class="tree-items ${isExpanded ? 'expanded' : ''}">
${tags.map(tag => {
const img = getImg(tag);
const name = getName(tag, state.lang);
const isSelected = state.selected.has(tag.mrf);
return `
<div class="tree-item ${isSelected ? 'selected' : ''}" data-mrf="${tag.mrf}">
${img
? `<img class="tree-img" src="${img}" alt="${tag.ref}">`
: `<div class="tree-placeholder">${tag.ref?.slice(0, 1) || 'T'}</div>`
}
<span class="tree-name">${name}</span>
</div>
`;
}).join('')}
</div>
</div>
`;
}).join('');
this.bindEvents();
}
private bindEvents(): void {
const state = this.getState();
delegateEvent<MouseEvent>(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<MouseEvent>(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);
}
});
}
}

View File

@@ -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<AppState>;
protected unsubscribe?: () => void;
constructor(container: HTMLElement, store: Store<AppState>) {
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<AppState>): void {
this.store.setState(partial);
}
}

View File

@@ -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';

View File

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="es">
<head>
<title>DECK</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div class="app">
<!-- TOPBAR -->
<div class="topbar">
<div class="topbar-left">
<span class="logo">DECK</span>
<select id="lang-select" class="btn btn-sm">
<option value="es">ES</option>
<option value="en">EN</option>
<option value="ch">CH</option>
</select>
<button class="btn btn-sm" id="btn-api">API</button>
</div>
<div class="topbar-center">
<!-- Taxonomía -->
<div class="base-buttons">
<button class="base-btn active" data-base="hst">HST</button>
<button class="base-btn" data-base="flg">FLG</button>
<button class="base-btn" data-base="itm">ITM</button>
<button class="base-btn" data-base="loc">LOC</button>
<button class="base-btn" data-base="ply">PLY</button>
</div>
<!-- Maestros -->
<div class="base-buttons">
<button class="base-btn" data-base="mst">MST</button>
<button class="base-btn" data-base="bck">BCK</button>
</div>
<!-- Registro -->
<div class="base-buttons">
<button class="base-btn" data-base="mth">MTH</button>
<button class="base-btn" data-base="atc">ATC</button>
</div>
<!-- Comunicación -->
<div class="base-buttons">
<button class="base-btn" data-base="mail">MAIL</button>
<button class="base-btn" data-base="chat">CHAT</button>
</div>
<!-- Servicios -->
<div class="base-buttons">
<button class="base-btn" data-base="key">KEY</button>
<button class="base-btn" data-base="mindlink">MIND</button>
</div>
</div>
<div class="topbar-right">
<div class="search-box">
<input type="text" id="search" class="search-input" placeholder="Buscar...">
</div>
</div>
</div>
<!-- VIEW BAR -->
<div class="view-bar">
<div class="sel-group">
<button class="sel-btn" id="btn-sel">SEL</button>
<button class="sel-btn" id="btn-get">GET</button>
<span id="sel-count"></span>
</div>
<div class="view-tabs">
<button class="view-tab active" data-view="grid">Grid</button>
<button class="view-tab" data-view="tree">Tree</button>
<button class="view-tab" data-view="graph">Graph</button>
</div>
<div class="view-bar-spacer"></div>
</div>
<!-- GROUPS BAR -->
<div id="groups-bar" class="groups-bar"></div>
<!-- MAIN LAYOUT -->
<div class="main-layout">
<!-- LEFT PANEL - Libraries -->
<div class="left-panel" id="left-panel">
<div class="lib-icon active" data-lib="all" title="Todos"><span>ALL</span></div>
</div>
<!-- CENTER PANEL -->
<div class="center-panel">
<div id="content-area" class="content-area grid-view">
<div class="loading">Cargando...</div>
</div>
</div>
<!-- RIGHT PANEL - Detail -->
<div id="detail-panel" class="detail-panel"></div>
</div>
<!-- API MODAL -->
<div id="api-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>API Reference</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="api-item">
<div class="api-endpoint">GET /api/{base}</div>
<div class="api-desc">Lista tags (base: hst, flg, itm, loc, ply)</div>
</div>
<div class="api-item">
<div class="api-endpoint">POST /api/rpc/api_children</div>
<div class="api-desc">Obtener hijos de un tag</div>
</div>
<div class="api-item">
<div class="api-endpoint">POST /api/rpc/api_related</div>
<div class="api-desc">Obtener tags relacionados</div>
</div>
<div class="api-item">
<div class="api-endpoint">GET /api/graph_hst</div>
<div class="api-desc">Relaciones del grafo</div>
</div>
<div class="api-item">
<div class="api-endpoint">GET /api/tree_hst</div>
<div class="api-desc">Jerarquias</div>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
import { API_BASE } from '@/config/index.ts';
interface FetchOptions {
method?: 'GET' | 'POST';
body?: Record<string, unknown>;
schema?: string; // PostgREST Accept-Profile header
}
export async function apiClient<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<T> {
const { method = 'GET', body, schema } = options;
const headers: Record<string, string> = {};
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<T>(
endpoint: string,
options: FetchOptions = {},
fallback: T
): Promise<T> {
try {
return await apiClient<T>(endpoint, options);
} catch {
console.error(`API call failed: ${endpoint}`);
return fallback;
}
}

View File

@@ -0,0 +1,8 @@
import { apiClientSafe } from './client.ts';
import type { GraphEdge, TreeEdge } from '@/types/index.ts';
export const fetchGraphEdges = (): Promise<GraphEdge[]> =>
apiClientSafe<GraphEdge[]>('/graph_hst', {}, []);
export const fetchTreeEdges = (): Promise<TreeEdge[]> =>
apiClientSafe<TreeEdge[]>('/tree_hst', {}, []);

View File

@@ -0,0 +1,5 @@
import { apiClientSafe } from './client.ts';
import type { Group } from '@/types/index.ts';
export const fetchGroups = (): Promise<Group[]> =>
apiClientSafe<Group[]>('/api_groups', {}, []);

View File

@@ -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';

View File

@@ -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<Library[]> => {
// 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<Library[]>(`/api_library_list_${base}`, {}, []);
};
export const fetchLibraryMembers = async (mrf: string, base: BaseType): Promise<string[]> => {
if (!LIBRARY_BASES.has(base)) {
return [];
}
const data = await apiClientSafe<Array<{ mrf_tag: string }>>(
`/library_${base}?mrf_library=eq.${mrf}`,
{},
[]
);
return data.map(d => d.mrf_tag);
};

View File

@@ -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<Tag[]> => {
const { schema, table } = getSchemaAndTable(base);
return apiClientSafe<Tag[]>(
`/${table}?order=ref.asc`,
schema ? { schema } : {},
[]
);
};
// Fetch HST tags for group name resolution (set_hst points to hst tags)
export const fetchHstTags = (): Promise<Tag[]> =>
apiClientSafe<Tag[]>('/hst?select=mrf,ref,alias,name_es,name_en,name_ch', {}, []);
export const fetchChildren = (mrf: string): Promise<ChildTag[]> =>
apiClientSafe<ChildTag[]>('/rpc/api_children', {
method: 'POST',
body: { parent_mrf: mrf }
}, []);
export const fetchRelated = (mrf: string): Promise<RelatedTag[]> =>
apiClientSafe<RelatedTag[]>('/rpc/api_related', {
method: 'POST',
body: { tag_mrf: mrf }
}, []);

View File

@@ -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<CardProps> {
protected template(): string {
const { tag, lang, selected, selectionMode } = this.props;
const img = getImg(tag);
const name = getName(tag, lang);
return `
<div class="card ${selected ? 'selected' : ''}" data-mrf="${tag.mrf}">
${selectionMode ? `
<input type="checkbox" class="card-checkbox" ${selected ? 'checked' : ''}>
` : ''}
${img
? `<img class="card-img" src="${img}" alt="${tag.ref}" loading="lazy">`
: `<div class="card-placeholder">${tag.ref?.slice(0, 2) || 'T'}</div>`
}
<div class="card-name">${name}</div>
</div>
`;
}
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);
}
});
}
}

View File

@@ -0,0 +1,42 @@
export abstract class Component<P extends object = object> {
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<P>): 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;
}
}

View File

@@ -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<ModalProps> {
protected template(): string {
const { title, content, isOpen } = this.props;
return `
<div class="modal ${isOpen ? 'open' : ''}">
<div class="modal-content">
<div class="modal-header">
<h3>${title}</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
${content}
</div>
</div>
</div>
`;
}
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');
}
}

View File

@@ -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<TagChipProps> {
protected template(): string {
const { mrf, label, title } = this.props;
return `
<span class="tag-chip" data-mrf="${mrf}" title="${title || ''}">
${label}
</span>
`;
}
protected bindEvents(): void {
this.element.addEventListener('click', () => {
this.props.onClick(this.props.mrf);
});
}
}

View File

@@ -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';

View File

@@ -0,0 +1 @@
export const API_BASE = '/api';

View File

@@ -0,0 +1,15 @@
import type { CategoryKey } from '@/types/index.ts';
export interface CategoryConfig {
name: string;
color: string;
}
export const CATS: Record<CategoryKey, CategoryConfig> = {
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' }
};

View File

@@ -0,0 +1,14 @@
import type { EdgeType } from '@/types/index.ts';
export const EDGE_COLORS: Record<EdgeType, string> = {
relation: '#8BC34A',
specialization: '#9C27B0',
mirror: '#607D8B',
dependency: '#2196F3',
sequence: '#4CAF50',
composition: '#FF9800',
hierarchy: '#E91E63',
library: '#00BCD4',
contextual: '#FFC107',
association: '#795548'
};

View File

@@ -0,0 +1,3 @@
export { CATS, type CategoryConfig } from './categories.ts';
export { EDGE_COLORS } from './edges.ts';
export { API_BASE } from './api.ts';

View File

@@ -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<void> {
this.router.parseHash();
await this.init();
this.bindEvents();
}
private async init(): Promise<void> {
const contentArea = $('#content-area');
const detailPanelEl = $('#detail-panel');
if (!contentArea || !detailPanelEl) return;
// Update UI
this.updateBaseButtons();
this.updateViewTabs();
// Show loading
contentArea.innerHTML = '<div class="loading">Cargando...</div>';
// 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<string, number>();
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 = `
<button class="group-btn ${state.group === 'all' ? 'active' : ''}" data-group="all">
Todos (${state.tags.length})
</button>
${sorted.map(([groupMrf, count]) => {
const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap);
return `
<button class="group-btn ${state.group === groupMrf ? 'active' : ''}" data-group="${groupMrf}">
${groupName} (${count})
</button>
`;
}).join('')}
`;
delegateEvent<MouseEvent>(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 = `
<div class="lib-icon ${state.library === 'all' ? 'active' : ''}" data-lib="all" title="Todos">
<span>ALL</span>
</div>
${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 `
<div class="lib-icon ${state.library === lib.mrf ? 'active' : ''}" data-lib="${lib.mrf}" title="${name}">
${icon ? `<img src="${icon}" alt="">` : ''}
<span>${name.slice(0, 8)}</span>
</div>
`;
}).join('')}
`;
delegateEvent<MouseEvent>(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 = `
<div class="graph-options">
<!-- Stats -->
<div class="graph-section">
<div class="graph-stat">
<span>Nodos</span>
<span class="graph-stat-value">${nodeCount}</span>
</div>
<div class="graph-stat">
<span>Edges</span>
<span class="graph-stat-value">${edgeCount}</span>
</div>
</div>
<!-- Categories -->
<div class="graph-section">
<div class="graph-section-title">Categorias</div>
${Object.entries(CATS).map(([key, config]) => `
<label class="graph-checkbox">
<input type="checkbox" data-cat="${key}" ${graphFilters.cats.has(key as CategoryKey) ? 'checked' : ''}>
<span class="color-dot" style="background: ${config.color}"></span>
${config.name}
</label>
`).join('')}
</div>
<!-- Edge Types -->
<div class="graph-section">
<div class="graph-section-title">Relaciones</div>
${Object.entries(EDGE_COLORS).map(([key, color]) => `
<label class="graph-checkbox">
<input type="checkbox" data-edge="${key}" ${graphFilters.edges.has(key as EdgeType) ? 'checked' : ''}>
<span class="color-dot" style="background: ${color}"></span>
${key}
</label>
`).join('')}
</div>
<!-- Visualization -->
<div class="graph-section">
<div class="graph-section-title">Visualizacion</div>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-img" ${graphSettings.showImg ? 'checked' : ''}>
Imagenes
</label>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-lbl" ${graphSettings.showLbl ? 'checked' : ''}>
Etiquetas
</label>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Nodo</span>
<span class="graph-slider-value" id="node-size-val">${graphSettings.nodeSize}px</span>
</div>
<input type="range" id="graph-node-size" min="10" max="60" value="${graphSettings.nodeSize}">
</div>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Distancia</span>
<span class="graph-slider-value" id="link-dist-val">${graphSettings.linkDist}px</span>
</div>
<input type="range" id="graph-link-dist" min="30" max="200" value="${graphSettings.linkDist}">
</div>
</div>
</div>
`;
// Bind events
this.bindGraphOptionEvents(container);
}
private bindGraphOptionEvents(container: HTMLElement): void {
// Category checkboxes
container.querySelectorAll<HTMLInputElement>('[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<HTMLInputElement>('[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<HTMLInputElement>('#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<HTMLInputElement>('#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<HTMLInputElement>('#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<HTMLInputElement>('#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<MouseEvent>(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<MouseEvent>(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();
});

View File

@@ -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<BaseType, BaseConfig> = {
// ═══════════════════════════════════════════════════════════════
// 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<ModuleCategory, BaseConfig[]> => {
const result: Record<ModuleCategory, BaseConfig[]> = {
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';
};

View File

@@ -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<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">💬</div>
<div class="module-disabled-title">Context Manager</div>
<div class="module-disabled-text">Chat con IA - Próximamente</div>
</div>
`;
}
}
export default ContextModule;

View File

@@ -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<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">🔑</div>
<div class="module-disabled-title">Keys</div>
<div class="module-disabled-text">Gestión de claves - Próximamente</div>
</div>
`;
}
}
export default KeyModule;

View File

@@ -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<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">📧</div>
<div class="module-disabled-title">Mail Assistant</div>
<div class="module-disabled-text">Interfaz de chat con IA - Próximamente</div>
</div>
`;
}
}
export default MailModule;

View File

@@ -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<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">🔗</div>
<div class="module-disabled-title">MindLink</div>
<div class="module-disabled-text">Gestión de hipervínculos - Próximamente</div>
</div>
`;
}
}
export default MindlinkModule;

View File

@@ -0,0 +1,4 @@
export { MailModule } from './MailModule.ts';
export { ContextModule } from './ContextModule.ts';
export { KeyModule } from './KeyModule.ts';
export { MindlinkModule } from './MindlinkModule.ts';

View File

@@ -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';

View File

@@ -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<AppState>;
private currentModule: BaseModule | null = null;
private currentBase: BaseType | null = null;
constructor(store: Store<AppState>) {
this.store = store;
}
/**
* Cargar módulo para una base
*/
async load(base: BaseType, targets: LoaderTargets): Promise<void> {
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 = '<div class="loading">Cargando...</div>';
// 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 = `
<div class="module-disabled">
<div class="module-disabled-icon">🚧</div>
<div class="module-disabled-title">${moduleName}</div>
<div class="module-disabled-text">Próximamente</div>
</div>
`;
}
private showErrorMessage(container: HTMLElement, message: string): void {
container.innerHTML = `
<div class="module-error">
<div class="module-error-icon">⚠️</div>
<div class="module-error-text">${message}</div>
</div>
`;
}
}
export default ModuleLoader;

View File

@@ -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<ModuleState>;
// 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<AppState>;
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<void>;
abstract unmount(): void;
abstract render(): void;
// Override para carga de datos específica
async loadData(): Promise<void> {
// 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<AppState> {
return this.ctx.store.getState();
}
protected setState(partial: Partial<AppState>): 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<BaseType, BaseConfig>, 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<BaseType, BaseConfig>
): Record<ModuleCategory, BaseConfig[]> => {
const result: Record<ModuleCategory, BaseConfig[]> = {
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<BaseType, BaseConfig>
): BaseConfig[] => {
return Object.values(configs).filter(c => c.enabled !== false);
};

View File

@@ -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<void> {
// Show loading
this.ctx.container.innerHTML = '<div class="loading">Cargando...</div>';
// 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<void> {
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 = '<div class="sidebar-empty">Sin bibliotecas</div>';
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 = `
<div class="lib-icon ${state.library === 'all' ? 'active' : ''}" data-lib="all" title="Todos">
<span>ALL</span>
</div>
${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 `
<div class="lib-icon ${state.library === lib.mrf ? 'active' : ''}" data-lib="${lib.mrf}" title="${name}">
${icon ? `<img src="${icon}" alt="">` : ''}
<span>${name.slice(0, 8)}</span>
</div>
`;
}).join('')}
`;
// Bind library clicks
delegateEvent<MouseEvent>(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<string, number>();
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 = `
<button class="group-btn ${state.group === 'all' ? 'active' : ''}" data-group="all">
Todos (${state.tags.length})
</button>
${sorted.map(([groupMrf, count]) => {
const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap);
return `
<button class="group-btn ${state.group === groupMrf ? 'active' : ''}" data-group="${groupMrf}">
${groupName} (${count})
</button>
`;
}).join('')}
`;
// Bind group clicks
delegateEvent<MouseEvent>(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 = `
<div class="graph-options">
<div class="graph-section">
<div class="graph-stat">
<span>Nodos</span>
<span class="graph-stat-value">${tags.length}</span>
</div>
<div class="graph-stat">
<span>Edges</span>
<span class="graph-stat-value">${graphEdges.length}</span>
</div>
</div>
<div class="graph-section">
<div class="graph-section-title">Visualización</div>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-img" ${graphSettings.showImg ? 'checked' : ''}>
Imágenes
</label>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-lbl" ${graphSettings.showLbl ? 'checked' : ''}>
Etiquetas
</label>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Nodo</span>
<span class="graph-slider-value" id="node-size-val">${graphSettings.nodeSize}px</span>
</div>
<input type="range" id="graph-node-size" min="10" max="60" value="${graphSettings.nodeSize}">
</div>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Distancia</span>
<span class="graph-slider-value" id="link-dist-val">${graphSettings.linkDist}px</span>
</div>
<input type="range" id="graph-link-dist" min="30" max="200" value="${graphSettings.linkDist}">
</div>
</div>
</div>
`;
this.bindGraphOptionEvents(container);
}
private bindGraphOptionEvents(container: HTMLElement): void {
// Show images checkbox
const showImgCb = container.querySelector<HTMLInputElement>('#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<HTMLInputElement>('#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<HTMLInputElement>('#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<HTMLInputElement>('#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;

View File

@@ -0,0 +1 @@
export { StandardModule, default } from './StandardModule.ts';

View File

@@ -0,0 +1 @@
export { Router } from './router.ts';

View File

@@ -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<AppState>;
private onNavigate: () => void;
constructor(store: Store<AppState>, 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();
}
}

View File

@@ -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';

View File

@@ -0,0 +1,27 @@
type Listener<T> = (state: T, prevState: T) => void;
export interface Store<T extends object> {
getState: () => Readonly<T>;
setState: (partial: Partial<T>) => void;
subscribe: (listener: Listener<T>) => () => void;
}
export function createStore<T extends object>(initialState: T): Store<T> {
let state = { ...initialState };
const listeners = new Set<Listener<T>>();
return {
getState: (): Readonly<T> => state,
setState: (partial: Partial<T>): void => {
const prevState = state;
state = { ...state, ...partial };
listeners.forEach(fn => fn(state, prevState));
},
subscribe: (listener: Listener<T>): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
}
};
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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<CategoryKey>;
edges: Set<EdgeType>;
}
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<string>;
// Selection
selectionMode: boolean;
selected: Set<string>;
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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,14 @@
import { toast } from './toast.ts';
export async function copyToClipboard(text: string, message?: string): Promise<void> {
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)}...`);
}

Some files were not shown because too many files have changed in this diff Show More