Files
captain-claude/deck-frontend/deck-v4.6.html
ARCHITECT f199daf4ba Change PIN to 1451
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 23:31:52 +00:00

1865 lines
68 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>deck</title>
<meta name="description" content="DECK Tag Management System">
<meta name="robots" content="noindex, nofollow">
<meta name="googlebot" content="noindex, nofollow">
<!--
DECK FRONTEND v4.6 - EventBus decoupling
Extract: ./extract.sh deck.html [output_dir]
-->
<style>
/* =============================================================================
* DECK STYLES v4.6
* ============================================================================= */
/* -----------------------------------------------------------------------------
* 1. VARIABLES
* ----------------------------------------------------------------------------- */
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a24;
--bg-hover: #252535;
--border-color: #2a2a3a;
--border-radius-sm: 4px;
--border-radius-md: 6px;
--border-radius-lg: 8px;
--border-radius-pill: 16px;
--text-primary: #e0e0e0;
--text-muted: #888888;
--text-accent: #7c8aff;
--accent: #7c8aff;
--accent-hover: #9aa4ff;
--topbar-height: 50px;
--toolbar-height: 44px;
--left-panel-width: 85px;
--detail-panel-width: 360px;
--card-width: 176px;
--card-img-height: 176px;
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
}
/* -----------------------------------------------------------------------------
* 2. RESET & BASE
* ----------------------------------------------------------------------------- */
*, *:before, *:after { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.4;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg-secondary); }
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: #444; }
/* -----------------------------------------------------------------------------
* 3. LAYOUT
* ----------------------------------------------------------------------------- */
.app { display: flex; flex-direction: column; height: 100vh; }
.topbar {
height: var(--topbar-height);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
flex-shrink: 0;
z-index: 20;
}
.topbar__left, .topbar__right { display: flex; align-items: center; gap: 10px; }
.topbar__center { flex: 1; display: flex; justify-content: center; gap: 16px; overflow-x: auto; }
.toolbar {
height: var(--toolbar-height);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
flex-shrink: 0;
}
.toolbar__left { display: flex; align-items: center; gap: 6px; }
.toolbar__center { flex: 1; display: flex; gap: 6px; overflow-x: auto; padding: 4px 0; }
.toolbar__right { display: flex; gap: 4px; }
.main-layout { flex: 1; display: flex; overflow: hidden; }
/* -----------------------------------------------------------------------------
* 4. COMPONENTS
* ----------------------------------------------------------------------------- */
.logo { font-weight: 700; font-size: 1.2em; color: var(--accent); letter-spacing: 1px; user-select: none; }
.btn {
padding: 7px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
color: var(--text-muted);
cursor: pointer;
font-size: 0.8em;
font-weight: 500;
transition: all var(--transition-fast);
white-space: nowrap;
}
.btn:hover:not(.btn--disabled) { border-color: var(--accent); color: var(--text-primary); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn--active { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn--disabled { opacity: 0.5; cursor: not-allowed; }
.btn--sm { padding: 5px 10px; font-size: 0.75em; }
.base-group { display: flex; gap: 2px; background: var(--bg-card); border-radius: var(--border-radius-md); padding: 3px; }
.base-btn {
padding: 6px 12px;
background: transparent;
border: none;
border-radius: var(--border-radius-sm);
color: var(--text-muted);
cursor: pointer;
font-size: 0.75em;
font-weight: 600;
transition: all var(--transition-fast);
white-space: nowrap;
}
.base-btn:hover { color: var(--text-primary); }
.base-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.base-btn--active { background: var(--accent); color: #fff; }
.view-tab {
padding: 6px 16px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
color: var(--text-muted);
cursor: pointer;
font-size: 0.85em;
transition: all var(--transition-fast);
}
.view-tab:hover { color: var(--text-primary); }
.view-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.view-tab--active { background: var(--accent); border-color: var(--accent); color: #fff; }
.group-btn {
padding: 5px 12px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-pill);
color: var(--text-muted);
cursor: pointer;
font-size: 0.75em;
white-space: nowrap;
transition: all var(--transition-fast);
}
.group-btn:hover { border-color: var(--accent); color: var(--text-primary); }
.group-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.group-btn--active { background: var(--accent); border-color: var(--accent); color: #fff; }
.search-input {
width: 280px;
padding: 9px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
color: var(--text-primary);
font-size: 0.9em;
}
.search-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(124, 138, 255, 0.2); }
.search-input::placeholder { color: var(--text-muted); }
.lang-select {
padding: 5px 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 0.8em;
cursor: pointer;
}
.lang-select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Left Panel */
.left-panel {
width: var(--left-panel-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
flex-shrink: 0;
}
.lib-icon {
flex-shrink: 0;
width: 65px;
height: 65px;
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-lg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
overflow: hidden;
}
.lib-icon:hover { border-color: var(--accent); }
.lib-icon:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.lib-icon--active { border-color: var(--accent); background: var(--bg-hover); }
.lib-icon img { width: 42px; height: 42px; object-fit: cover; border-radius: var(--border-radius-sm); }
.lib-icon span {
font-size: 0.6em;
color: var(--text-muted);
text-align: center;
margin-top: 2px;
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Center Panel */
.center-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; min-width: 0; }
.content-area { flex: 1; overflow: auto; padding: 16px; }
/* Detail Panel */
.detail-panel {
width: 0;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
overflow: hidden;
transition: width var(--transition-normal);
flex-shrink: 0;
}
.detail-panel--open { width: var(--detail-panel-width); }
.detail-content { width: var(--detail-panel-width); height: 100%; overflow-y: auto; }
.detail-header {
position: relative;
width: 100%;
height: 200px;
background: linear-gradient(145deg, var(--bg-card), var(--bg-primary));
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.detail-close {
position: absolute;
top: 10px;
right: 10px;
background: var(--bg-hover);
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 20px;
cursor: pointer;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
z-index: 2;
}
.detail-close:hover { background: var(--accent); color: #fff; }
.detail-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.detail-img { width: 150px; height: 150px; object-fit: cover; border-radius: 12px; }
.detail-placeholder { font-size: 4em; font-weight: 700; color: var(--accent); opacity: 0.4; text-transform: uppercase; }
.detail-body { padding: 20px; }
.detail-ref { font-size: 0.9em; color: var(--accent); font-weight: 600; margin-bottom: 4px; }
.detail-mrf {
font-size: 0.65em;
color: var(--text-muted);
font-family: monospace;
cursor: pointer;
padding: 6px 10px;
background: var(--bg-card);
border-radius: var(--border-radius-sm);
margin-bottom: 12px;
word-break: break-all;
transition: all var(--transition-fast);
}
.detail-mrf:hover { color: var(--accent); background: var(--bg-hover); }
.detail-name { font-size: 1.2em; font-weight: 600; margin-bottom: 8px; }
.detail-desc { font-size: 0.9em; color: var(--text-muted); line-height: 1.5; margin-bottom: 16px; }
.detail-section { margin-top: 20px; }
.detail-section h4 { font-size: 0.8em; color: var(--accent); text-transform: uppercase; margin-bottom: 10px; }
.chip-list { display: flex; flex-wrap: wrap; gap: 6px; }
.tag-chip {
padding: 6px 12px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-pill);
font-size: 0.8em;
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.tag-chip:hover { border-color: var(--accent); color: var(--text-primary); }
.tag-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal--open { display: flex; }
.modal-content {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
max-width: 500px;
width: 90%;
position: relative;
}
.modal-close {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-muted);
}
.modal-close:hover { color: var(--text-primary); }
.modal-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.modal h2 { margin-bottom: 16px; color: var(--accent); }
.modal code { background: var(--bg-primary); padding: 2px 6px; border-radius: var(--border-radius-sm); font-size: 0.85em; }
.modal ul { margin: 8px 0 0 20px; line-height: 1.8; }
/* Toast */
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--accent);
color: #fff;
padding: 14px 28px;
border-radius: 10px;
font-size: 0.9em;
font-weight: 500;
opacity: 0;
transition: all var(--transition-normal);
z-index: 1000;
pointer-events: none;
}
.toast--show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* -----------------------------------------------------------------------------
* 5. VIEWS
* ----------------------------------------------------------------------------- */
/* Grid View */
.content-area--grid { display: flex; flex-wrap: wrap; gap: 16px; align-content: start; }
.card {
width: var(--card-width);
flex-shrink: 0;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
overflow: hidden;
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
}
.card:hover { border-color: var(--accent); transform: translateY(-2px); }
.card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.card--selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
.card__checkbox { position: absolute; top: 8px; left: 8px; z-index: 2; width: 20px; height: 20px; accent-color: var(--accent); }
.card__img { display: block; width: 100%; height: var(--card-img-height); object-fit: cover; background: var(--bg-hover); }
.card__placeholder {
width: 100%;
height: var(--card-img-height);
background: linear-gradient(135deg, var(--bg-hover), var(--bg-card));
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5em;
font-weight: 700;
color: var(--accent);
opacity: 0.6;
text-transform: uppercase;
}
.card__name {
padding: 10px;
font-size: 0.85em;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Tree View */
.content-area--tree { display: flex; flex-direction: column; gap: 2px; }
.tree-node { border-left: 1px solid var(--border-color); margin-left: 10px; }
.tree-node[data-depth="0"] { border-left: none; margin-left: 0; }
.tree-children { display: none; }
.tree-children--expanded { display: block; }
.tree-toggle { width: 20px; font-weight: bold; color: var(--accent); text-align: center; user-select: none; cursor: pointer; flex-shrink: 0; }
.tree-toggle--empty { visibility: hidden; }
.tree-count { color: var(--text-muted); font-size: 0.75em; margin-left: auto; }
.tree-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: var(--border-radius-md);
cursor: pointer;
transition: background var(--transition-fast);
}
.tree-item:hover { background: var(--bg-hover); }
.tree-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tree-item--parent .tree-toggle:hover { color: var(--accent-hover); }
.tree-item__img { width: 28px; height: 28px; object-fit: cover; border-radius: var(--border-radius-sm); flex-shrink: 0; }
.tree-item__placeholder {
width: 28px;
height: 28px;
background: var(--bg-hover);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.75em;
color: var(--accent);
flex-shrink: 0;
}
.tree-item__name { font-size: 0.85em; }
/* Graph View */
.content-area--graph { position: relative; padding: 0; overflow: hidden; }
.graph-container { width: 100%; height: 100%; position: relative; }
.graph-container svg { display: block; width: 100%; height: 100%; }
.graph-sidebar {
position: absolute;
top: 16px;
left: 16px;
width: 200px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
padding: 12px;
z-index: 10;
max-height: calc(100% - 32px);
overflow-y: auto;
}
.graph-section { margin-bottom: 16px; }
.graph-section:last-child { margin-bottom: 0; }
.graph-section__title { font-size: 0.75em; font-weight: 600; color: var(--accent); text-transform: uppercase; margin-bottom: 8px; }
.graph-stat { display: flex; justify-content: space-between; font-size: 0.85em; margin-bottom: 4px; }
.graph-stat__value { font-weight: 600; color: var(--accent); }
.graph-checkbox { display: flex; align-items: center; gap: 8px; font-size: 0.8em; margin-bottom: 4px; cursor: pointer; }
.graph-checkbox input { cursor: pointer; accent-color: var(--accent); }
.color-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.graph-slider { margin-top: 8px; }
.graph-slider__label { display: flex; justify-content: space-between; font-size: 0.75em; margin-bottom: 4px; }
.graph-slider__value { color: var(--accent); }
.graph-slider input[type="range"] {
width: 100%;
height: 4px;
background: var(--border-color);
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;
}
.graph-controls { position: absolute; top: 16px; right: 16px; display: flex; gap: 6px; z-index: 10; }
.graph-legend {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 8px 16px;
z-index: 10;
}
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.75em; }
/* -----------------------------------------------------------------------------
* 6. UTILITIES
* ----------------------------------------------------------------------------- */
.loading, .empty { display: flex; align-items: center; justify-content: center; padding: 60px; color: var(--text-muted); font-size: 0.9em; }
.sel-count { font-size: 0.75em; color: var(--accent); margin-left: 6px; }
.hidden { display: none !important; }
/* -----------------------------------------------------------------------------
* 7. RESPONSIVE
* ----------------------------------------------------------------------------- */
@media (max-width: 1024px) {
:root {
--left-panel-width: 70px;
--detail-panel-width: 300px;
--card-width: 150px;
--card-img-height: 150px;
}
.lib-icon { width: 55px; height: 55px; }
.lib-icon img { width: 36px; height: 36px; }
.search-input { width: 200px; }
.graph-sidebar { width: 180px; }
}
@media (max-width: 768px) {
:root {
--topbar-height: 45px;
--toolbar-height: 40px;
--card-width: 140px;
--card-img-height: 140px;
}
.left-panel { display: none; }
.detail-panel--open { width: 100%; position: absolute; right: 0; top: 0; bottom: 0; z-index: 50; }
.search-input { width: 150px; }
.topbar__center { gap: 8px; }
.base-group { padding: 2px; }
.base-btn { padding: 5px 8px; font-size: 0.7em; }
.graph-sidebar { width: 160px; font-size: 0.9em; }
.graph-legend { padding: 6px 12px; gap: 10px; }
}
@media (max-width: 480px) {
:root {
--card-width: 120px;
--card-img-height: 120px;
}
.topbar { padding: 0 8px; gap: 8px; }
.toolbar { padding: 0 8px; }
.content-area { padding: 8px; }
.content-area--grid { gap: 8px; }
.search-input { width: 120px; padding: 7px 10px; }
.btn { padding: 5px 10px; }
.view-tab { padding: 5px 10px; font-size: 0.8em; }
.graph-sidebar { display: none; }
}
/* -----------------------------------------------------------------------------
* 8. REDUCED MOTION
* ----------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* -----------------------------------------------------------------------------
* 9. LOCK SCREEN
* ----------------------------------------------------------------------------- */
.lock-screen {
position: fixed;
inset: 0;
background: var(--bg-primary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
}
.lock-screen.hidden { display: none; }
.lock-logo { font-size: 2rem; font-weight: 700; color: var(--accent); margin-bottom: 2rem; letter-spacing: 0.1em; }
.lock-dots { display: flex; gap: 12px; margin-bottom: 2rem; }
.lock-dot {
width: 14px; height: 14px;
border-radius: 50%;
background: var(--border-color);
transition: all 0.15s ease;
}
.lock-dot.filled { background: var(--accent); }
.lock-dot.error { background: #ff4444; animation: shake 0.3s ease; }
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.lock-keypad {
display: grid;
grid-template-columns: repeat(3, 70px);
gap: 12px;
}
.lock-key {
width: 70px; height: 70px;
border-radius: 50%;
border: 2px solid var(--border-color);
background: transparent;
color: var(--text-primary);
font-size: 1.5rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
}
.lock-key:hover { border-color: var(--accent); background: var(--bg-hover); }
.lock-key:active { transform: scale(0.95); }
.lock-key.empty { visibility: hidden; }
.lock-key.backspace { font-size: 1.2rem; }
</style>
</head>
<body>
<!-- LOCK SCREEN -->
<div id="lock-screen" class="lock-screen">
<div class="lock-logo">DECK</div>
<div class="lock-dots">
<div class="lock-dot"></div>
<div class="lock-dot"></div>
<div class="lock-dot"></div>
<div class="lock-dot"></div>
</div>
<div class="lock-keypad">
<button class="lock-key" data-key="1">1</button>
<button class="lock-key" data-key="2">2</button>
<button class="lock-key" data-key="3">3</button>
<button class="lock-key" data-key="4">4</button>
<button class="lock-key" data-key="5">5</button>
<button class="lock-key" data-key="6">6</button>
<button class="lock-key" data-key="7">7</button>
<button class="lock-key" data-key="8">8</button>
<button class="lock-key" data-key="9">9</button>
<button class="lock-key empty"></button>
<button class="lock-key" data-key="0">0</button>
<button class="lock-key backspace" data-key="back"></button>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════════
HTML STRUCTURE
══════════════════════════════════════════════════════════════════════════ -->
<div class="app">
<!-- TOPBAR -->
<header class="topbar" role="banner">
<div class="topbar__left">
<span class="logo">DECK</span>
<select id="lang-select" class="lang-select" aria-label="Seleccionar idioma">
<option value="es">ES</option>
<option value="en">EN</option>
<option value="ch">CH</option>
</select>
<button class="btn" id="btn-api" aria-label="Ver referencia API">API</button>
</div>
<div class="topbar__center" role="navigation" aria-label="Bases de datos">
<div class="base-group" data-group="taxonomy" role="group" aria-label="Taxonomía">
<button class="base-btn base-btn--active" data-base="hst" aria-pressed="true">HST</button>
<button class="base-btn" data-base="flg" aria-pressed="false">FLG</button>
<button class="base-btn" data-base="itm" aria-pressed="false">ITM</button>
<button class="base-btn" data-base="loc" aria-pressed="false">LOC</button>
<button class="base-btn" data-base="ply" aria-pressed="false">PLY</button>
</div>
<div class="base-group" data-group="production" role="group" aria-label="Producción">
<button class="base-btn" data-base="mst" aria-pressed="false">MST</button>
<button class="base-btn" data-base="bck" aria-pressed="false">BCK</button>
<button class="base-btn" data-base="mth" aria-pressed="false">MTH</button>
</div>
<div class="base-group" data-group="secretaria" role="group" aria-label="Secretaría">
<button class="base-btn" data-base="atc" aria-pressed="false">ATC</button>
<button class="base-btn" data-base="oracle" aria-pressed="false">Oracle</button>
</div>
<div class="base-group" data-group="comms" role="group" aria-label="Comunicación">
<button class="base-btn" data-base="mail" aria-pressed="false">MAIL</button>
<button class="base-btn" data-base="chat" aria-pressed="false">CHAT</button>
</div>
</div>
<div class="topbar__right">
<input type="text" id="search" class="search-input" placeholder="Buscar... (/)" aria-label="Buscar tags">
</div>
</header>
<!-- TOOLBAR -->
<div class="toolbar" role="toolbar" aria-label="Herramientas">
<div class="toolbar__left">
<button class="btn" id="btn-sel" aria-label="Modo selección" aria-pressed="false">SEL</button>
<button class="btn" id="btn-get" aria-label="Copiar MRFs seleccionados">GET<span class="sel-count hidden" id="sel-count" aria-live="polite"></span></button>
</div>
<div class="toolbar__center" id="groups-bar" role="group" aria-label="Filtros de grupo"></div>
<div class="toolbar__right" role="group" aria-label="Tipo de vista">
<button class="view-tab view-tab--active" data-view="grid" aria-pressed="true">Grid</button>
<button class="view-tab" data-view="tree" aria-pressed="false">Tree</button>
<button class="view-tab" data-view="graph" aria-pressed="false">Graph</button>
</div>
</div>
<!-- MAIN LAYOUT -->
<div class="main-layout">
<aside class="left-panel" id="left-panel" role="complementary" aria-label="Bibliotecas"></aside>
<main class="center-panel" role="main">
<div class="content-area" id="content-area" aria-live="polite">
<div class="loading">Cargando...</div>
</div>
</main>
<aside class="detail-panel" id="detail-panel" role="complementary" aria-label="Detalle del tag">
<div class="detail-content" id="detail-content"></div>
</aside>
</div>
</div>
<!-- API MODAL -->
<div class="modal" id="api-modal" role="dialog" aria-labelledby="api-modal-title" aria-modal="true">
<div class="modal-content">
<button class="modal-close" aria-label="Cerrar modal">&times;</button>
<h2 id="api-modal-title">API Reference</h2>
<div class="api-info">
<p><strong>Base URL:</strong> <code>/api</code></p>
<p style="margin-top: 12px;"><strong>Headers:</strong></p>
<ul>
<li>GET: <code>Accept-Profile: {schema}</code></li>
<li>POST: <code>Content-Profile: {schema}</code></li>
</ul>
<p style="margin-top: 16px;"><strong>Schemas:</strong></p>
<ul style="font-size: 0.9em;">
<li>HST, FLG → <code>tzzr_core_hst</code></li>
<li>ITM, LOC, PLY → <code>tzzr_core_itm_base</code></li>
<li>MTH → <code>tzzr_core_produccion</code></li>
<li>ATC → <code>tzzr_core_secretaria</code></li>
<li>MAIL → <code>mail_manager</code></li>
<li>CHAT → <code>context_manager</code></li>
<li>Groups, Libraries → <code>public</code></li>
</ul>
</div>
</div>
</div>
<!-- TOAST -->
<div class="toast" id="toast" role="alert" aria-live="assertive"></div>
<script>
/**
* DECK Frontend v4.6
* EventBus pattern for module decoupling
*/
// =============================================================================
// 0. LOCK SCREEN
// =============================================================================
const Lock = {
PIN: "1451", // PIN code - can be changed
entered: "",
init() {
if (sessionStorage.getItem("deck_unlocked") === "true") {
this.unlock();
return true;
}
this.bind();
return false;
},
bind() {
const keypad = document.querySelector(".lock-keypad");
if (!keypad) return;
keypad.addEventListener("click", (e) => {
const key = e.target.closest(".lock-key");
if (!key) return;
const value = key.dataset.key;
if (value === "back") {
this.entered = this.entered.slice(0, -1);
} else if (value && this.entered.length < 4) {
this.entered += value;
}
this.updateDots();
if (this.entered.length === 4) {
setTimeout(() => this.check(), 150);
}
});
// Keyboard support
document.addEventListener("keydown", (e) => {
if (document.getElementById("lock-screen")?.classList.contains("hidden")) return;
if (e.key >= "0" && e.key <= "9" && this.entered.length < 4) {
this.entered += e.key;
this.updateDots();
if (this.entered.length === 4) setTimeout(() => this.check(), 150);
} else if (e.key === "Backspace") {
this.entered = this.entered.slice(0, -1);
this.updateDots();
}
});
},
updateDots() {
const dots = document.querySelectorAll(".lock-dot");
dots.forEach((dot, i) => {
dot.classList.toggle("filled", i < this.entered.length);
dot.classList.remove("error");
});
},
check() {
if (this.entered === this.PIN) {
sessionStorage.setItem("deck_unlocked", "true");
this.unlock();
} else {
this.showError();
}
},
showError() {
const dots = document.querySelectorAll(".lock-dot");
dots.forEach(dot => dot.classList.add("error"));
setTimeout(() => {
this.entered = "";
this.updateDots();
}, 500);
},
unlock() {
const screen = document.getElementById("lock-screen");
if (screen) screen.classList.add("hidden");
}
};
// =============================================================================
// 1. CONFIG
// =============================================================================
const CONFIG = {
API_BASE: "/api",
IMG_BASE: "https://atc.tzzrdeck.me",
BASES: {
// Taxonomía
hst: { schema: "tzzr_core_hst", table: "hst", hasGroups: true, hasLibraries: true },
flg: { schema: "tzzr_core_hst", table: "flg", hasGroups: false, hasLibraries: true },
itm: { schema: "tzzr_core_itm_base", table: "itm", hasGroups: false, hasLibraries: true },
loc: { schema: "tzzr_core_itm_base", table: "loc", hasGroups: false, hasLibraries: true },
ply: { schema: "tzzr_core_itm_base", table: "ply", hasGroups: false, hasLibraries: true },
// Producción
mst: { schema: "tzzr_core_produccion", table: "mst", hasGroups: false, hasLibraries: true },
bck: { schema: "tzzr_core_produccion", table: "bck", hasGroups: false, hasLibraries: true },
mth: { schema: "tzzr_core_produccion", table: "mth", hasGroups: false, hasLibraries: true },
// Secretaría
atc: { schema: "tzzr_core_secretaria", table: "atc", hasGroups: false, hasLibraries: true },
oracle: { schema: "tzzr_core_secretaria", table: "oracle", hasGroups: false, hasLibraries: true },
// Comunicación
mail: { schema: "mail_manager", table: "clara_registros", hasGroups: false, hasLibraries: false, orderBy: "timestamp_entrada.desc" },
chat: { schema: "context_manager", table: "messages", hasGroups: false, hasLibraries: false, orderBy: "created_at.desc" }
},
CATEGORIES: {
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" },
ply: { name: "Players", color: "#FF5722" },
itm: { name: "Items", color: "#795548" },
loc: { name: "Locations", color: "#009688" },
mst: { name: "Masters", color: "#3F51B5" },
bck: { name: "Backlog", color: "#607D8B" },
mth: { name: "Methods", color: "#8BC34A" },
atc: { name: "Attachments",color: "#FF7043" },
ora: { name: "Oracle", color: "#AB47BC" }
},
EDGE_TYPES: {
relation: "#8BC34A", specialization: "#9C27B0", mirror: "#607D8B",
dependency: "#2196F3", sequence: "#4CAF50", composition: "#FF9800",
hierarchy: "#E91E63", library: "#00BCD4", contextual: "#FFC107", association: "#795548"
},
GRAPH_DEFAULTS: { nodeSize: 20, linkDist: 80, showImages: true, showLabels: true }
};
// =============================================================================
// 2. STATE
// =============================================================================
const State = {
_data: {
base: "hst", view: "grid", lang: "es",
search: "", group: "all", library: "all", libraryMembers: new Set(),
selectionMode: false, selected: new Set(), selectedTag: null,
tags: [], hstTags: [], groups: [], libraries: [], edges: [], treeData: [], tableRules: {},
graphFilters: { categories: new Set(Object.keys(CONFIG.CATEGORIES)), edgeTypes: new Set(Object.keys(CONFIG.EDGE_TYPES)) },
graphSettings: { ...CONFIG.GRAPH_DEFAULTS }
},
get(key) { return key ? this._data[key] : { ...this._data }; },
set(updates) { this._data = { ...this._data, ...updates }; },
resetFilters() {
this.set({ group: "all", library: "all", libraryMembers: new Set(), search: "", selected: new Set(), selectionMode: false });
}
};
// =============================================================================
// 3. EVENTS (desacoplamiento entre módulos)
// =============================================================================
const Events = {
_handlers: {},
_debug: true, // Toggle: Events._debug = false to disable
on(event, fn) {
(this._handlers[event] ||= []).push(fn);
if (this._debug) console.log(`%c[Events.on] ${event}`, 'color: #4CAF50', `(${this._handlers[event].length} handlers)`);
},
off(event, fn) {
if (!fn) delete this._handlers[event];
else this._handlers[event] = (this._handlers[event] || []).filter(h => h !== fn);
if (this._debug) console.log(`%c[Events.off] ${event}`, 'color: #FF9800');
},
emit(event, data) {
const handlers = this._handlers[event] || [];
if (this._debug) console.log(`%c[Events.emit] ${event}`, 'color: #7c8aff; font-weight: bold', data !== undefined ? data : '', `${handlers.length} handlers`);
handlers.forEach(fn => fn(data));
}
};
// =============================================================================
// 4. UTILS
// =============================================================================
const Utils = {
$(selector) { return document.querySelector(selector); },
$$(selector) { return document.querySelectorAll(selector); },
// XSS Protection
escapeHtml(str) {
if (!str) return "";
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
},
debounce(fn, delay) {
let timer;
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); };
},
resolveImage(url) {
if (!url) return "";
if (url.startsWith("http")) return url;
return `${CONFIG.IMG_BASE}/${url}`;
},
getName(tag, lang = "es") {
if (!tag) return "";
return tag[`name_${lang}`] || tag.name_es || tag.name_en || tag.alias || tag.ref || (tag.mrf ? tag.mrf.slice(0, 8) : "");
},
toast(message, duration = 2000) {
const el = Utils.$("#toast");
el.textContent = message;
el.classList.add("toast--show");
setTimeout(() => el.classList.remove("toast--show"), duration);
},
async copyToClipboard(text) {
try { await navigator.clipboard.writeText(text); return true; }
catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.cssText = "position:fixed;opacity:0";
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand("copy");
textarea.remove();
return success;
}
},
getCategory(tag) {
const base = State.get("base");
const config = CONFIG.BASES[base];
if (!config) return base;
// Check tableRules to see if this table has categories
const tableKey = `${config.schema}.${config.table}`;
const tableRules = State.get("tableRules") || {};
const hasCategories = tableRules[tableKey];
// If table has no categories defined, use base name
if (!hasCategories) return base;
// Use set_hst to find category
const setHst = tag.set_hst;
if (!setHst) return base;
const hstTags = State.get("hstTags") || [];
const categoryTag = hstTags.find(t => t.mrf === setHst);
const cat = categoryTag?.ref;
return (cat && CONFIG.CATEGORIES[cat]) ? cat : base;
},
updateHash() {
const { base, view } = State.get();
const parts = [base];
if (view !== "grid") parts.push(view);
window.location.hash = "/" + parts.join("/") + "/";
},
parseHash() {
const hash = window.location.hash.replace(/^#\/?/, "").replace(/\/?$/, "");
const parts = hash.split("/").filter(Boolean);
const result = {};
if (parts[0] && CONFIG.BASES[parts[0]]) result.base = parts[0];
if (parts[1] && ["grid", "tree", "graph"].includes(parts[1])) result.view = parts[1];
return result;
}
};
// =============================================================================
// 5. API
// =============================================================================
const API = {
async fetch(endpoint, schema, options = {}) {
const headers = {};
if (schema) headers[options.method === "POST" ? "Content-Profile" : "Accept-Profile"] = schema;
if (options.body) headers["Content-Type"] = "application/json";
try {
const response = await fetch(`${CONFIG.API_BASE}${endpoint}`, {
method: options.method || "GET", headers,
body: options.body ? JSON.stringify(options.body) : undefined
});
if (!response.ok) throw new Error(`API Error: ${response.status}`);
return response.json();
} catch (error) {
console.error(`API request failed: ${endpoint}`, error);
return options.fallback !== undefined ? options.fallback : [];
}
},
getTags(base) {
const config = CONFIG.BASES[base];
if (!config) return Promise.resolve([]);
return this.fetch(`/${config.table}?order=${config.orderBy || "ref.asc"}`, config.schema, { fallback: [] });
},
getHstTags() { return this.fetch("/hst?select=mrf,ref,name_es,name_en,alias", "tzzr_core_hst", { fallback: [] }); },
getGroups() { return this.fetch("/api_groups", "public", { fallback: [] }); },
async getTableRules() {
const rules = await this.fetch("/hst_rules?select=tabla,hst_permitidos", "tzzr_core_hst", { fallback: [] });
// Convert to map: "schema.table" -> has categories (hst_permitidos.length > 0)
const map = {};
rules.forEach(r => { map[r.tabla] = r.hst_permitidos && r.hst_permitidos.length > 0; });
return map;
},
async getLibraries(base) {
const config = CONFIG.BASES[base];
if (!config?.hasLibraries) return [];
// Get unique library MRFs from library_${base}
const relations = await this.fetch(`/library_${base}?select=mrf_library`, config.schema, { fallback: [] });
const uniqueMrfs = [...new Set(relations.map(r => r.mrf_library))];
if (uniqueMrfs.length === 0) return [];
// Fetch tag info for those MRFs
const mrfFilter = uniqueMrfs.map(m => `"${m}"`).join(',');
return this.fetch(`/${config.table}?mrf=in.(${mrfFilter})&select=mrf,alias,name_es,name_en,img_thumb_url`, config.schema, { fallback: [] });
},
async getLibraryMembers(base, libraryMrf) {
const config = CONFIG.BASES[base];
if (!config) return [];
const data = await this.fetch(`/library_${base}?mrf_library=eq.${libraryMrf}`, config.schema, { fallback: [] });
return data.map(item => item.mrf_tag || item.mrf_child || item.mrf);
},
getEdges(base) {
const config = CONFIG.BASES[base];
if (!config) return Promise.resolve([]);
return this.fetch(`/graph_${base}`, config.schema, { fallback: [] });
},
getTree(base) {
const config = CONFIG.BASES[base];
if (!config) return Promise.resolve([]);
return this.fetch(`/tree_${base}`, config.schema, { fallback: [] });
},
getChildren(mrf, base) {
const config = CONFIG.BASES[base];
if (!config) return Promise.resolve([]);
return this.fetch("/rpc/api_children", config.schema, { method: "POST", body: { parent_mrf: mrf }, fallback: [] });
},
getRelated(mrf, base) {
const config = CONFIG.BASES[base];
if (!config) return Promise.resolve([]);
return this.fetch("/rpc/api_related", config.schema, { method: "POST", body: { tag_mrf: mrf }, fallback: [] });
}
};
// =============================================================================
// 6. FILTER
// =============================================================================
const Filter = {
apply(tags) {
const { search, group, library, libraryMembers, lang } = State.get();
const query = search.toLowerCase();
return tags.filter(tag => {
if (library !== "all" && !libraryMembers.has(tag.mrf)) return false;
if (group !== "all" && tag.set_hst !== group) return false;
if (query) {
const name = Utils.getName(tag, lang).toLowerCase();
const ref = (tag.ref || "").toLowerCase();
const alias = (tag.alias || "").toLowerCase();
if (!name.includes(query) && !ref.includes(query) && !alias.includes(query)) return false;
}
return true;
});
}
};
// =============================================================================
// 7. COMPONENTS
// =============================================================================
const GroupsBar = {
el: null,
init() {
this.el = Utils.$("#groups-bar");
this.el.addEventListener("click", (e) => {
const btn = e.target.closest(".group-btn");
if (!btn) return;
State.set({ group: btn.dataset.group });
this.render();
Events.emit('render');
});
},
render() {
const { base, tags, groups, group: activeGroup, lang } = State.get();
const config = CONFIG.BASES[base];
if (!config?.hasGroups || !groups.length) { this.el.innerHTML = ""; return; }
const allBtn = `<button class="group-btn ${activeGroup === "all" ? "group-btn--active" : ""}" data-group="all" aria-pressed="${activeGroup === "all"}">Todos (${tags.length})</button>`;
const groupBtns = groups.map(g => {
const name = Utils.escapeHtml(Utils.getName(g, lang));
return `<button class="group-btn ${activeGroup === g.mrf ? "group-btn--active" : ""}" data-group="${Utils.escapeHtml(g.mrf)}" aria-pressed="${activeGroup === g.mrf}">${name} (${g.count || 0})</button>`;
}).join("");
this.el.innerHTML = allBtn + groupBtns;
}
};
const LibrariesPanel = {
el: null,
init() {
this.el = Utils.$("#left-panel");
this.el.addEventListener("click", async (e) => {
const item = e.target.closest(".lib-icon");
if (!item) return;
const mrf = item.dataset.lib;
const base = State.get("base");
if (mrf === "all") {
State.set({ library: "all", libraryMembers: new Set() });
} else {
const members = await API.getLibraryMembers(base, mrf);
State.set({ library: mrf, libraryMembers: new Set(members) });
}
this.render();
Events.emit('render');
});
},
render() {
const { base, libraries, library: activeLib, lang } = State.get();
const config = CONFIG.BASES[base];
if (!config?.hasLibraries) {
this.el.innerHTML = '<div class="empty" style="font-size:0.7em;padding:20px">—</div>';
return;
}
const allIcon = `<div class="lib-icon ${activeLib === "all" ? "lib-icon--active" : ""}" data-lib="all" title="Todas" tabindex="0" role="button" aria-pressed="${activeLib === "all"}"><span>ALL</span></div>`;
const libIcons = libraries.map(lib => {
const name = Utils.escapeHtml(Utils.getName(lib, lang));
const img = Utils.resolveImage(lib.img_thumb_url || lib.icon_url);
const mrf = Utils.escapeHtml(lib.mrf);
return `<div class="lib-icon ${activeLib === lib.mrf ? "lib-icon--active" : ""}" data-lib="${mrf}" title="${name}" tabindex="0" role="button" aria-pressed="${activeLib === lib.mrf}">${img ? `<img src="${img}" alt="${name}">` : `<span>${name.slice(0, 4)}</span>`}</div>`;
}).join("");
this.el.innerHTML = allIcon + libIcons;
}
};
const DetailPanel = {
el: null,
contentEl: null,
init() {
this.el = Utils.$("#detail-panel");
this.contentEl = Utils.$("#detail-content");
this.el.addEventListener("click", async (e) => {
if (e.target.closest(".detail-close")) { this.close(); return; }
if (e.target.closest(".detail-mrf")) {
await Utils.copyToClipboard(e.target.textContent.trim());
Utils.toast("MRF copiado");
return;
}
const chip = e.target.closest(".tag-chip");
if (chip?.dataset.mrf) this.show(chip.dataset.mrf);
});
},
async show(mrf) {
const { tags, hstTags, lang, base } = State.get();
const tag = tags.find(t => t.mrf === mrf) || hstTags.find(t => t.mrf === mrf);
if (!tag) { Utils.toast("Tag no encontrado"); return; }
State.set({ selectedTag: tag });
const [children, related] = await Promise.all([API.getChildren(mrf, base), API.getRelated(mrf, base)]);
const name = Utils.escapeHtml(Utils.getName(tag, lang));
const img = Utils.resolveImage(tag.img_thumb_url || tag.img_url);
const ref = Utils.escapeHtml(tag.ref || "");
const desc = Utils.escapeHtml(tag.txt || tag.alias || "");
const safeMrf = Utils.escapeHtml(mrf);
this.contentEl.innerHTML = `
<div class="detail-header">
<button class="detail-close" aria-label="Cerrar detalle">&times;</button>
${img ? `<img class="detail-img" src="${img}" alt="${ref}">` : `<div class="detail-placeholder">${ref.slice(0, 2) || "?"}</div>`}
</div>
<div class="detail-body">
<div class="detail-ref">${ref}</div>
<div class="detail-mrf" title="Click para copiar" tabindex="0" role="button">${safeMrf}</div>
<div class="detail-name">${name}</div>
${desc ? `<div class="detail-desc">${desc}</div>` : ""}
${children.length ? `<div class="detail-section"><h4>Hijos (${children.length})</h4><div class="chip-list">${children.map(c => `<span class="tag-chip" data-mrf="${Utils.escapeHtml(c.mrf)}" tabindex="0" role="button">${Utils.escapeHtml(Utils.getName(c, lang))}</span>`).join("")}</div></div>` : ""}
${related.length ? `<div class="detail-section"><h4>Relacionados (${related.length})</h4><div class="chip-list">${related.map(r => `<span class="tag-chip" data-mrf="${Utils.escapeHtml(r.mrf)}" title="${Utils.escapeHtml(r.edge_type || "")}" tabindex="0" role="button">${Utils.escapeHtml(Utils.getName(r, lang))}</span>`).join("")}</div></div>` : ""}
</div>
`;
this.el.classList.add("detail-panel--open");
},
close() {
this.el.classList.remove("detail-panel--open");
State.set({ selectedTag: null });
}
};
// =============================================================================
// 8. VIEWS
// =============================================================================
const GridView = {
render(tags) {
const area = Utils.$("#content-area");
area.className = "content-area content-area--grid";
if (!tags.length) { area.innerHTML = '<div class="empty">Sin resultados</div>'; return; }
const { lang, selectionMode, selected } = State.get();
area.innerHTML = tags.map(tag => {
const name = Utils.escapeHtml(Utils.getName(tag, lang));
const img = Utils.resolveImage(tag.img_thumb_url);
const ref = Utils.escapeHtml(tag.ref || "");
const mrf = Utils.escapeHtml(tag.mrf);
const isSelected = selected.has(tag.mrf);
return `
<div class="card ${isSelected ? "card--selected" : ""}" data-mrf="${mrf}" tabindex="0" role="button" aria-pressed="${isSelected}">
${selectionMode ? `<input type="checkbox" class="card__checkbox" ${isSelected ? "checked" : ""} aria-label="Seleccionar ${name}">` : ""}
${img ? `<img class="card__img" src="${img}" alt="${name}" loading="lazy">` : `<div class="card__placeholder">${ref.slice(0, 2) || "?"}</div>`}
<div class="card__name">${name}</div>
</div>
`;
}).join("");
}
};
const TreeView = {
render(tags) {
const area = Utils.$("#content-area");
area.className = "content-area content-area--tree";
if (!tags.length) { area.innerHTML = '<div class="empty">Sin resultados</div>'; return; }
const { lang, treeData } = State.get();
// Create tag lookup map
const tagMap = new Map();
tags.forEach(tag => tagMap.set(tag.mrf, tag));
// Create children map from tree_* data
const childrenMap = new Map(); // parent_mrf → [child_mrfs]
const hasParent = new Set(); // mrfs that have a parent
treeData.forEach(rel => {
if (tagMap.has(rel.mrf_child)) { // Only if child is in filtered tags
if (!childrenMap.has(rel.mrf_parent)) childrenMap.set(rel.mrf_parent, []);
childrenMap.get(rel.mrf_parent).push(rel.mrf_child);
hasParent.add(rel.mrf_child);
}
});
// Find root nodes: tags that don't have a parent (or parent not in tagMap)
const roots = tags.filter(tag => !hasParent.has(tag.mrf));
// Recursive render function
const renderNode = (mrf, depth = 0) => {
const tag = tagMap.get(mrf);
if (!tag) return "";
const name = Utils.escapeHtml(Utils.getName(tag, lang));
const img = Utils.resolveImage(tag.img_thumb_url);
const ref = Utils.escapeHtml(tag.ref || "");
const safeMrf = Utils.escapeHtml(mrf);
const children = childrenMap.get(mrf) || [];
const hasChildren = children.length > 0;
return `
<div class="tree-node" data-depth="${depth}">
<div class="tree-item ${hasChildren ? 'tree-item--parent' : ''}" data-mrf="${safeMrf}" tabindex="0" role="button" style="padding-left: ${12 + depth * 20}px">
${hasChildren ? '<span class="tree-toggle" aria-hidden="true">+</span>' : '<span class="tree-toggle tree-toggle--empty"></span>'}
${img ? `<img class="tree-item__img" src="${img}" alt="${name}">` : `<div class="tree-item__placeholder">${ref.slice(0, 1) || "?"}</div>`}
<span class="tree-item__name">${name}</span>
${hasChildren ? `<span class="tree-count">${children.length}</span>` : ''}
</div>
${hasChildren ? `<div class="tree-children" role="group">${children.map(c => renderNode(c, depth + 1)).join("")}</div>` : ''}
</div>
`;
};
if (!roots.length) {
area.innerHTML = '<div class="empty">Sin jerarquía definida</div>';
return;
}
area.innerHTML = roots.map(tag => renderNode(tag.mrf, 0)).join("");
// Bind toggle events
area.querySelectorAll(".tree-item--parent").forEach(item => {
const toggle = item.querySelector(".tree-toggle");
const children = item.nextElementSibling;
item.onclick = item.onkeydown = (e) => {
// Don't toggle if clicking to select (will be handled by content-area click)
if (e.target.closest(".tree-item__img, .tree-item__name, .tree-item__placeholder")) return;
if (e.type === "keydown" && e.key !== "Enter" && e.key !== " ") return;
if (e.type === "keydown") e.preventDefault();
if (children) {
const isExpanded = children.classList.toggle("tree-children--expanded");
toggle.textContent = isExpanded ? "" : "+";
}
};
});
}
};
const GraphView = {
svg: null,
simulation: null,
zoom: null,
d3Loaded: false,
async render(tags) {
const area = Utils.$("#content-area");
area.className = "content-area content-area--graph";
// Clean up previous simulation
this.unmount();
if (!tags.length) { area.innerHTML = '<div class="empty">Sin resultados</div>'; return; }
if (!this.d3Loaded && typeof d3 === "undefined") {
area.innerHTML = '<div class="loading">Cargando visualización...</div>';
await this.loadD3();
}
const { graphFilters, graphSettings, edges, lang } = State.get();
const nodeMap = new Map();
tags.forEach(tag => {
const cat = Utils.getCategory(tag);
if (graphFilters.categories.has(cat)) {
nodeMap.set(tag.mrf, {
id: tag.mrf, ref: tag.ref || "",
name: Utils.getName(tag, lang),
img: Utils.resolveImage(tag.img_thumb_url),
category: cat, color: CONFIG.CATEGORIES[cat]?.color || "#7c8aff"
});
}
});
const links = edges
.filter(e => nodeMap.has(e.mrf_a) && nodeMap.has(e.mrf_b) && graphFilters.edgeTypes.has(e.edge_type))
.map(e => ({ source: e.mrf_a, target: e.mrf_b, type: e.edge_type, weight: e.weight || 1, color: CONFIG.EDGE_TYPES[e.edge_type] || "#666" }));
const nodes = Array.from(nodeMap.values());
area.innerHTML = '<div class="graph-container"></div>';
const container = area.querySelector(".graph-container");
// Count tags per category
const categoryCounts = new Map();
tags.forEach(tag => {
const cat = Utils.getCategory(tag);
categoryCounts.set(cat, (categoryCounts.get(cat) || 0) + 1);
});
this.renderSVG(container, nodes, links);
this.renderSidebar(container, nodes.length, links.length, categoryCounts);
this.renderControls(container);
this.renderLegend(container);
},
async loadD3() {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://d3js.org/d3.v7.min.js";
script.onload = () => { this.d3Loaded = true; resolve(); };
script.onerror = reject;
document.head.appendChild(script);
});
},
renderSVG(container, nodes, links) {
const { graphSettings } = State.get();
const width = container.clientWidth || 800;
const height = container.clientHeight || 600;
this.svg = d3.select(container).append("svg").attr("width", width).attr("height", height).attr("role", "img").attr("aria-label", "Grafo de relaciones");
const g = this.svg.append("g");
this.zoom = d3.zoom().scaleExtent([0.1, 4]).on("zoom", (e) => g.attr("transform", e.transform));
this.svg.call(this.zoom);
const hasLinks = links.length > 0;
this.simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id).distance(graphSettings.linkDist))
.force("charge", d3.forceManyBody().strength(hasLinks ? -150 : -30))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(graphSettings.nodeSize + 5));
if (!hasLinks) {
this.simulation.force("radial", d3.forceRadial(Math.min(width, height) / 3, width / 2, height / 2).strength(0.1));
}
const link = g.append("g").selectAll("line").data(links).join("line")
.attr("stroke", d => d.color).attr("stroke-width", d => Math.max(1.5, Math.sqrt(d.weight)));
const node = g.append("g").selectAll("g").data(nodes).join("g").call(this.createDrag());
node.append("circle").attr("r", graphSettings.nodeSize).attr("fill", d => d.color).attr("stroke", "#fff").attr("stroke-width", 2);
if (graphSettings.showImages) {
node.each(function(d) {
if (d.img) {
d3.select(this).append("image")
.attr("xlink:href", d.img)
.attr("x", -graphSettings.nodeSize).attr("y", -graphSettings.nodeSize)
.attr("width", graphSettings.nodeSize * 2).attr("height", graphSettings.nodeSize * 2)
.attr("clip-path", `circle(${graphSettings.nodeSize}px)`);
}
});
}
if (graphSettings.showLabels) {
node.append("text").text(d => d.name.slice(0, 12))
.attr("dy", graphSettings.nodeSize + 14).attr("text-anchor", "middle")
.attr("fill", "#e0e0e0").attr("font-size", "10px");
}
node.on("click", (e, d) => { e.stopPropagation(); Events.emit('detail:show', d.id); });
this.simulation.on("tick", () => {
link.attr("x1", d => d.source.x).attr("y1", d => d.source.y).attr("x2", d => d.target.x).attr("y2", d => d.target.y);
node.attr("transform", d => `translate(${d.x},${d.y})`);
});
},
createDrag() {
const sim = this.simulation;
return d3.drag()
.on("start", (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on("drag", (e, d) => { d.fx = e.x; d.fy = e.y; })
.on("end", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; });
},
renderSidebar(container, nodeCount, edgeCount, categoryCounts) {
const { graphFilters, graphSettings } = State.get();
const sidebar = document.createElement("div");
sidebar.className = "graph-sidebar";
// Only show categories that have tags in current data
const categoryHTML = Object.entries(CONFIG.CATEGORIES)
.filter(([key]) => categoryCounts.has(key) && categoryCounts.get(key) > 0)
.map(([key, val]) => {
const count = categoryCounts.get(key) || 0;
return `<label class="graph-checkbox"><input type="checkbox" data-category="${key}" ${graphFilters.categories.has(key) ? "checked" : ""}><span class="color-dot" style="background:${val.color}"></span>${Utils.escapeHtml(val.name)} (${count})</label>`;
}).join("");
sidebar.innerHTML = `
<div class="graph-section">
<div class="graph-section__title">Stats</div>
<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>
<div class="graph-section">
<div class="graph-section__title">Categorías</div>
${categoryHTML || '<span class="empty">Sin categorías</span>'}
</div>
<div class="graph-section">
<div class="graph-section__title">Relaciones</div>
${Object.entries(CONFIG.EDGE_TYPES).map(([key, color]) => `<label class="graph-checkbox"><input type="checkbox" data-edge="${key}" ${graphFilters.edgeTypes.has(key) ? "checked" : ""}><span class="color-dot" style="background:${color}"></span>${Utils.escapeHtml(key)}</label>`).join("")}
</div>
<div class="graph-section">
<div class="graph-section__title">Visual</div>
<label class="graph-checkbox"><input type="checkbox" id="gs-images" ${graphSettings.showImages ? "checked" : ""}>Imágenes</label>
<label class="graph-checkbox"><input type="checkbox" id="gs-labels" ${graphSettings.showLabels ? "checked" : ""}>Etiquetas</label>
<div class="graph-slider">
<div class="graph-slider__label"><span>Nodo</span><span class="graph-slider__value" id="gs-size-val">${graphSettings.nodeSize}px</span></div>
<input type="range" id="gs-size" min="10" max="60" value="${graphSettings.nodeSize}" aria-label="Tamaño de nodo">
</div>
<div class="graph-slider">
<div class="graph-slider__label"><span>Distancia</span><span class="graph-slider__value" id="gs-dist-val">${graphSettings.linkDist}px</span></div>
<input type="range" id="gs-dist" min="30" max="200" value="${graphSettings.linkDist}" aria-label="Distancia entre nodos">
</div>
</div>
`;
container.appendChild(sidebar);
this.bindSidebarEvents(sidebar);
},
bindSidebarEvents(sidebar) {
sidebar.querySelectorAll("[data-category]").forEach(cb => {
cb.onchange = () => {
const { graphFilters } = State.get();
const cats = new Set(graphFilters.categories);
cb.checked ? cats.add(cb.dataset.category) : cats.delete(cb.dataset.category);
State.set({ graphFilters: { ...graphFilters, categories: cats } });
Events.emit('render');
};
});
sidebar.querySelectorAll("[data-edge]").forEach(cb => {
cb.onchange = () => {
const { graphFilters } = State.get();
const edges = new Set(graphFilters.edgeTypes);
cb.checked ? edges.add(cb.dataset.edge) : edges.delete(cb.dataset.edge);
State.set({ graphFilters: { ...graphFilters, edgeTypes: edges } });
Events.emit('render');
};
});
sidebar.querySelector("#gs-images").onchange = (e) => { const { graphSettings } = State.get(); State.set({ graphSettings: { ...graphSettings, showImages: e.target.checked } }); Events.emit('render'); };
sidebar.querySelector("#gs-labels").onchange = (e) => { const { graphSettings } = State.get(); State.set({ graphSettings: { ...graphSettings, showLabels: e.target.checked } }); Events.emit('render'); };
const sizeSlider = sidebar.querySelector("#gs-size");
sizeSlider.oninput = (e) => { sidebar.querySelector("#gs-size-val").textContent = e.target.value + "px"; };
sizeSlider.onchange = (e) => { const { graphSettings } = State.get(); State.set({ graphSettings: { ...graphSettings, nodeSize: parseInt(e.target.value) } }); Events.emit('render'); };
const distSlider = sidebar.querySelector("#gs-dist");
distSlider.oninput = (e) => { sidebar.querySelector("#gs-dist-val").textContent = e.target.value + "px"; };
distSlider.onchange = (e) => { const { graphSettings } = State.get(); State.set({ graphSettings: { ...graphSettings, linkDist: parseInt(e.target.value) } }); Events.emit('render'); };
},
renderControls(container) {
const controls = document.createElement("div");
controls.className = "graph-controls";
controls.innerHTML = `<button class="btn btn--sm" id="graph-fit" aria-label="Ajustar vista">Ajustar</button><button class="btn btn--sm" id="graph-zin" aria-label="Acercar">+</button><button class="btn btn--sm" id="graph-zout" aria-label="Alejar"></button>`;
container.appendChild(controls);
controls.querySelector("#graph-fit").onclick = () => { this.svg.transition().duration(500).call(this.zoom.transform, d3.zoomIdentity); };
controls.querySelector("#graph-zin").onclick = () => { this.svg.transition().duration(300).call(this.zoom.scaleBy, 1.5); };
controls.querySelector("#graph-zout").onclick = () => { this.svg.transition().duration(300).call(this.zoom.scaleBy, 0.67); };
},
renderLegend(container) {
const { graphFilters } = State.get();
const legend = document.createElement("div");
legend.className = "graph-legend";
legend.setAttribute("role", "legend");
legend.innerHTML = Array.from(graphFilters.categories).map(cat => `<div class="legend-item"><span class="color-dot" style="background:${CONFIG.CATEGORIES[cat]?.color || "#7c8aff"}"></span><span>${Utils.escapeHtml(CONFIG.CATEGORIES[cat]?.name || cat)}</span></div>`).join("");
container.appendChild(legend);
},
unmount() {
if (this.simulation) { this.simulation.stop(); this.simulation = null; }
if (this.svg) { this.svg.remove(); this.svg = null; }
this.zoom = null;
}
};
// =============================================================================
// 9. APP
// =============================================================================
const App = {
async init() {
const hashState = Utils.parseHash();
if (hashState.base) State.set({ base: hashState.base });
if (hashState.view) State.set({ view: hashState.view });
// Event subscriptions (desacoplamiento)
Events.on('render', () => this.renderView());
Events.on('detail:show', (mrf) => DetailPanel.show(mrf));
GroupsBar.init();
LibrariesPanel.init();
DetailPanel.init();
this.bindEvents();
await this.loadData(State.get("base"));
this.renderView();
this.updateBaseButtons();
this.updateViewTabs();
},
bindEvents() {
Utils.$$(".base-btn").forEach(btn => { btn.onclick = () => this.changeBase(btn.dataset.base); });
Utils.$$(".view-tab").forEach(tab => { tab.onclick = () => this.changeView(tab.dataset.view); });
Utils.$("#lang-select").onchange = (e) => {
State.set({ lang: e.target.value });
this.renderView();
const selectedTag = State.get("selectedTag");
if (selectedTag) DetailPanel.show(selectedTag.mrf);
};
const searchInput = Utils.$("#search");
searchInput.oninput = Utils.debounce(() => { State.set({ search: searchInput.value }); this.renderView(); }, 200);
Utils.$("#btn-sel").onclick = () => {
const currentMode = State.get("selectionMode");
State.set({ selectionMode: !currentMode });
if (!State.get("selectionMode")) State.set({ selected: new Set() });
const btn = Utils.$("#btn-sel");
btn.classList.toggle("btn--active", State.get("selectionMode"));
btn.setAttribute("aria-pressed", State.get("selectionMode"));
this.updateSelectionCount();
this.renderView();
};
Utils.$("#btn-get").onclick = async () => {
const selected = State.get("selected");
if (!selected.size) { Utils.toast("No hay seleccionados"); return; }
await Utils.copyToClipboard([...selected].join("\n"));
Utils.toast(`Copiados ${selected.size} MRFs`);
};
Utils.$("#btn-api").onclick = () => { Utils.$("#api-modal").classList.add("modal--open"); };
Utils.$(".modal-close").onclick = () => { Utils.$("#api-modal").classList.remove("modal--open"); };
Utils.$("#api-modal").onclick = (e) => { if (e.target.id === "api-modal") Utils.$("#api-modal").classList.remove("modal--open"); };
Utils.$("#content-area").onclick = (e) => {
const item = e.target.closest(".card, .tree-item");
if (!item) return;
const mrf = item.dataset.mrf;
if (!mrf) return;
const { selectionMode, selected } = State.get();
if (selectionMode) {
const newSelected = new Set(selected);
newSelected.has(mrf) ? newSelected.delete(mrf) : newSelected.add(mrf);
State.set({ selected: newSelected });
this.updateSelectionCount();
this.renderView();
} else {
DetailPanel.show(mrf);
}
};
document.onkeydown = (e) => {
if (e.key === "Escape") {
DetailPanel.close();
Utils.$("#api-modal").classList.remove("modal--open");
if (State.get("selectionMode")) {
State.set({ selectionMode: false, selected: new Set() });
Utils.$("#btn-sel").classList.remove("btn--active");
Utils.$("#btn-sel").setAttribute("aria-pressed", "false");
this.updateSelectionCount();
this.renderView();
}
}
if (e.key === "/" && e.target.tagName !== "INPUT") { e.preventDefault(); Utils.$("#search").focus(); }
};
window.onhashchange = () => {
const hashState = Utils.parseHash();
if (hashState.base && hashState.base !== State.get("base")) this.changeBase(hashState.base);
if (hashState.view && hashState.view !== State.get("view")) this.changeView(hashState.view);
};
},
async loadData(base) {
Utils.$("#content-area").innerHTML = '<div class="loading">Cargando...</div>';
try {
// Load tableRules once (first load only)
let tableRules = State.get("tableRules");
if (!tableRules || Object.keys(tableRules).length === 0) {
tableRules = await API.getTableRules();
State.set({ tableRules });
}
const [tags, hstTags, groups, libraries, edges, treeData] = await Promise.all([
API.getTags(base), API.getHstTags(), API.getGroups(), API.getLibraries(base), API.getEdges(base), API.getTree(base)
]);
State.set({ tags, hstTags, groups, libraries, edges, treeData });
GroupsBar.render();
LibrariesPanel.render();
} catch (error) {
console.error("Failed to load data:", error);
Utils.$("#content-area").innerHTML = '<div class="empty">Error cargando datos</div>';
}
},
async changeBase(base) {
State.set({ base });
State.resetFilters();
State.set({ edges: [] });
Utils.$("#search").value = "";
this.updateBaseButtons();
DetailPanel.close();
GraphView.unmount();
Utils.updateHash();
await this.loadData(base);
this.renderView();
},
changeView(view) {
GraphView.unmount();
State.set({ view });
this.updateViewTabs();
DetailPanel.close();
Utils.updateHash();
this.renderView();
},
renderView() {
const { view, tags } = State.get();
const filtered = Filter.apply(tags);
switch (view) {
case "grid": GridView.render(filtered); break;
case "tree": TreeView.render(filtered); break;
case "graph": GraphView.render(filtered); break;
}
},
updateBaseButtons() {
const base = State.get("base");
Utils.$$(".base-btn").forEach(btn => {
const isActive = btn.dataset.base === base;
btn.classList.toggle("base-btn--active", isActive);
btn.setAttribute("aria-pressed", isActive);
});
},
updateViewTabs() {
const view = State.get("view");
Utils.$$(".view-tab").forEach(tab => {
const isActive = tab.dataset.view === view;
tab.classList.toggle("view-tab--active", isActive);
tab.setAttribute("aria-pressed", isActive);
});
},
updateSelectionCount() {
const count = State.get("selected").size;
const el = Utils.$("#sel-count");
if (count > 0) { el.textContent = `(${count})`; el.classList.remove("hidden"); }
else { el.classList.add("hidden"); }
}
};
document.addEventListener("DOMContentLoaded", () => {
const unlocked = Lock.init();
if (unlocked) {
App.init();
} else {
// Watch for unlock to init app
const observer = new MutationObserver((mutations) => {
const screen = document.getElementById("lock-screen");
if (screen?.classList.contains("hidden")) {
observer.disconnect();
App.init();
}
});
observer.observe(document.getElementById("lock-screen"), { attributes: true });
}
});
</script>
</body>
</html>