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:
20
apps/captain-mobile/captain-api.service
Normal file
20
apps/captain-mobile/captain-api.service
Normal 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
|
||||
482
apps/captain-mobile/captain_api.py
Normal file
482
apps/captain-mobile/captain_api.py
Normal 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)
|
||||
6
apps/captain-mobile/requirements.txt
Normal file
6
apps/captain-mobile/requirements.txt
Normal 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
|
||||
42
apps/captain-mobile/schema.sql
Normal file
42
apps/captain-mobile/schema.sql
Normal 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
1
apps/flow-ui
Submodule
Submodule apps/flow-ui added at f0c09b10ad
1
apps/mindlink
Submodule
1
apps/mindlink
Submodule
Submodule apps/mindlink added at 40c0944cf7
109
apps/storage/migrate_atc.py
Normal file
109
apps/storage/migrate_atc.py
Normal 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())
|
||||
9
apps/storage/requirements.txt
Normal file
9
apps/storage/requirements.txt
Normal 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
|
||||
20
apps/storage/storage-api.service
Normal file
20
apps/storage/storage-api.service
Normal 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
445
apps/storage/storage_api.py
Normal 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)
|
||||
480
apps/storage/storage_worker.py
Normal file
480
apps/storage/storage_worker.py
Normal 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())
|
||||
130
apps/storage/sync_metadata.py
Normal file
130
apps/storage/sync_metadata.py
Normal 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
1
apps/tzzr-cli
Submodule
Submodule apps/tzzr-cli added at 0327df5277
129
deck-frontend/backups/20260113_211524/index.html
Normal file
129
deck-frontend/backups/20260113_211524/index.html
Normal 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">×</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>
|
||||
43
deck-frontend/backups/20260113_211524/src/api/client.ts
Normal file
43
deck-frontend/backups/20260113_211524/src/api/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
deck-frontend/backups/20260113_211524/src/api/graph.ts
Normal file
8
deck-frontend/backups/20260113_211524/src/api/graph.ts
Normal 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', {}, []);
|
||||
5
deck-frontend/backups/20260113_211524/src/api/groups.ts
Normal file
5
deck-frontend/backups/20260113_211524/src/api/groups.ts
Normal 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', {}, []);
|
||||
5
deck-frontend/backups/20260113_211524/src/api/index.ts
Normal file
5
deck-frontend/backups/20260113_211524/src/api/index.ts
Normal 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';
|
||||
26
deck-frontend/backups/20260113_211524/src/api/libraries.ts
Normal file
26
deck-frontend/backups/20260113_211524/src/api/libraries.ts
Normal 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);
|
||||
};
|
||||
65
deck-frontend/backups/20260113_211524/src/api/tags.ts
Normal file
65
deck-frontend/backups/20260113_211524/src/api/tags.ts
Normal 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 }
|
||||
}, []);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">×</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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
1
deck-frontend/backups/20260113_211524/src/config/api.ts
Normal file
1
deck-frontend/backups/20260113_211524/src/config/api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const API_BASE = '/api';
|
||||
@@ -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' }
|
||||
};
|
||||
14
deck-frontend/backups/20260113_211524/src/config/edges.ts
Normal file
14
deck-frontend/backups/20260113_211524/src/config/edges.ts
Normal 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'
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CATS, type CategoryConfig } from './categories.ts';
|
||||
export { EDGE_COLORS } from './edges.ts';
|
||||
export { API_BASE } from './api.ts';
|
||||
484
deck-frontend/backups/20260113_211524/src/main.ts
Normal file
484
deck-frontend/backups/20260113_211524/src/main.ts
Normal 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();
|
||||
});
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { MailModule } from './MailModule.ts';
|
||||
export { ContextModule } from './ContextModule.ts';
|
||||
export { KeyModule } from './KeyModule.ts';
|
||||
export { MindlinkModule } from './MindlinkModule.ts';
|
||||
35
deck-frontend/backups/20260113_211524/src/modules/index.ts
Normal file
35
deck-frontend/backups/20260113_211524/src/modules/index.ts
Normal 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';
|
||||
174
deck-frontend/backups/20260113_211524/src/modules/loader.ts
Normal file
174
deck-frontend/backups/20260113_211524/src/modules/loader.ts
Normal 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;
|
||||
170
deck-frontend/backups/20260113_211524/src/modules/registry.ts
Normal file
170
deck-frontend/backups/20260113_211524/src/modules/registry.ts
Normal 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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { StandardModule, default } from './StandardModule.ts';
|
||||
@@ -0,0 +1 @@
|
||||
export { Router } from './router.ts';
|
||||
68
deck-frontend/backups/20260113_211524/src/router/router.ts
Normal file
68
deck-frontend/backups/20260113_211524/src/router/router.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
35
deck-frontend/backups/20260113_211524/src/state/index.ts
Normal file
35
deck-frontend/backups/20260113_211524/src/state/index.ts
Normal 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';
|
||||
27
deck-frontend/backups/20260113_211524/src/state/store.ts
Normal file
27
deck-frontend/backups/20260113_211524/src/state/store.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
701
deck-frontend/backups/20260113_211524/src/styles/main.css
Normal file
701
deck-frontend/backups/20260113_211524/src/styles/main.css
Normal 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;
|
||||
}
|
||||
37
deck-frontend/backups/20260113_211524/src/types/graph.ts
Normal file
37
deck-frontend/backups/20260113_211524/src/types/graph.ts
Normal 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;
|
||||
}
|
||||
16
deck-frontend/backups/20260113_211524/src/types/index.ts
Normal file
16
deck-frontend/backups/20260113_211524/src/types/index.ts
Normal 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';
|
||||
53
deck-frontend/backups/20260113_211524/src/types/state.ts
Normal file
53
deck-frontend/backups/20260113_211524/src/types/state.ts
Normal 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;
|
||||
}
|
||||
42
deck-frontend/backups/20260113_211524/src/types/tag.ts
Normal file
42
deck-frontend/backups/20260113_211524/src/types/tag.ts
Normal 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;
|
||||
}
|
||||
14
deck-frontend/backups/20260113_211524/src/utils/clipboard.ts
Normal file
14
deck-frontend/backups/20260113_211524/src/utils/clipboard.ts
Normal 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)}...`);
|
||||
}
|
||||
44
deck-frontend/backups/20260113_211524/src/utils/dom.ts
Normal file
44
deck-frontend/backups/20260113_211524/src/utils/dom.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
39
deck-frontend/backups/20260113_211524/src/utils/filters.ts
Normal file
39
deck-frontend/backups/20260113_211524/src/utils/filters.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
42
deck-frontend/backups/20260113_211524/src/utils/i18n.ts
Normal file
42
deck-frontend/backups/20260113_211524/src/utils/i18n.ts
Normal 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);
|
||||
}
|
||||
5
deck-frontend/backups/20260113_211524/src/utils/index.ts
Normal file
5
deck-frontend/backups/20260113_211524/src/utils/index.ts
Normal 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';
|
||||
21
deck-frontend/backups/20260113_211524/src/utils/toast.ts
Normal file
21
deck-frontend/backups/20260113_211524/src/utils/toast.ts
Normal 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);
|
||||
}
|
||||
@@ -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">×</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('');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
33
deck-frontend/backups/20260113_211524/src/views/View.ts
Normal file
33
deck-frontend/backups/20260113_211524/src/views/View.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
5
deck-frontend/backups/20260113_211524/src/views/index.ts
Normal file
5
deck-frontend/backups/20260113_211524/src/views/index.ts
Normal 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';
|
||||
129
deck-frontend/backups/20260113_212146/index.html
Normal file
129
deck-frontend/backups/20260113_212146/index.html
Normal 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">×</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>
|
||||
43
deck-frontend/backups/20260113_212146/src/api/client.ts
Normal file
43
deck-frontend/backups/20260113_212146/src/api/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
deck-frontend/backups/20260113_212146/src/api/graph.ts
Normal file
8
deck-frontend/backups/20260113_212146/src/api/graph.ts
Normal 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', {}, []);
|
||||
5
deck-frontend/backups/20260113_212146/src/api/groups.ts
Normal file
5
deck-frontend/backups/20260113_212146/src/api/groups.ts
Normal 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', {}, []);
|
||||
5
deck-frontend/backups/20260113_212146/src/api/index.ts
Normal file
5
deck-frontend/backups/20260113_212146/src/api/index.ts
Normal 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';
|
||||
26
deck-frontend/backups/20260113_212146/src/api/libraries.ts
Normal file
26
deck-frontend/backups/20260113_212146/src/api/libraries.ts
Normal 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);
|
||||
};
|
||||
65
deck-frontend/backups/20260113_212146/src/api/tags.ts
Normal file
65
deck-frontend/backups/20260113_212146/src/api/tags.ts
Normal 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 }
|
||||
}, []);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">×</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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
1
deck-frontend/backups/20260113_212146/src/config/api.ts
Normal file
1
deck-frontend/backups/20260113_212146/src/config/api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const API_BASE = '/api';
|
||||
@@ -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' }
|
||||
};
|
||||
14
deck-frontend/backups/20260113_212146/src/config/edges.ts
Normal file
14
deck-frontend/backups/20260113_212146/src/config/edges.ts
Normal 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'
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CATS, type CategoryConfig } from './categories.ts';
|
||||
export { EDGE_COLORS } from './edges.ts';
|
||||
export { API_BASE } from './api.ts';
|
||||
484
deck-frontend/backups/20260113_212146/src/main.ts
Normal file
484
deck-frontend/backups/20260113_212146/src/main.ts
Normal 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();
|
||||
});
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { MailModule } from './MailModule.ts';
|
||||
export { ContextModule } from './ContextModule.ts';
|
||||
export { KeyModule } from './KeyModule.ts';
|
||||
export { MindlinkModule } from './MindlinkModule.ts';
|
||||
35
deck-frontend/backups/20260113_212146/src/modules/index.ts
Normal file
35
deck-frontend/backups/20260113_212146/src/modules/index.ts
Normal 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';
|
||||
174
deck-frontend/backups/20260113_212146/src/modules/loader.ts
Normal file
174
deck-frontend/backups/20260113_212146/src/modules/loader.ts
Normal 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;
|
||||
170
deck-frontend/backups/20260113_212146/src/modules/registry.ts
Normal file
170
deck-frontend/backups/20260113_212146/src/modules/registry.ts
Normal 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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { StandardModule, default } from './StandardModule.ts';
|
||||
@@ -0,0 +1 @@
|
||||
export { Router } from './router.ts';
|
||||
62
deck-frontend/backups/20260113_212146/src/router/router.ts
Normal file
62
deck-frontend/backups/20260113_212146/src/router/router.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
35
deck-frontend/backups/20260113_212146/src/state/index.ts
Normal file
35
deck-frontend/backups/20260113_212146/src/state/index.ts
Normal 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';
|
||||
27
deck-frontend/backups/20260113_212146/src/state/store.ts
Normal file
27
deck-frontend/backups/20260113_212146/src/state/store.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
701
deck-frontend/backups/20260113_212146/src/styles/main.css
Normal file
701
deck-frontend/backups/20260113_212146/src/styles/main.css
Normal 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;
|
||||
}
|
||||
37
deck-frontend/backups/20260113_212146/src/types/graph.ts
Normal file
37
deck-frontend/backups/20260113_212146/src/types/graph.ts
Normal 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;
|
||||
}
|
||||
16
deck-frontend/backups/20260113_212146/src/types/index.ts
Normal file
16
deck-frontend/backups/20260113_212146/src/types/index.ts
Normal 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';
|
||||
53
deck-frontend/backups/20260113_212146/src/types/state.ts
Normal file
53
deck-frontend/backups/20260113_212146/src/types/state.ts
Normal 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;
|
||||
}
|
||||
42
deck-frontend/backups/20260113_212146/src/types/tag.ts
Normal file
42
deck-frontend/backups/20260113_212146/src/types/tag.ts
Normal 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;
|
||||
}
|
||||
14
deck-frontend/backups/20260113_212146/src/utils/clipboard.ts
Normal file
14
deck-frontend/backups/20260113_212146/src/utils/clipboard.ts
Normal 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
Reference in New Issue
Block a user