- Add tableRules to State for caching hst_rules data - Add getTableRules() API function to fetch hst_rules table - Modify getCategory() to check if table has categories (hst_permitidos) - Only use set_hst for sub-categorization on tables with non-empty hst_permitidos - Tables without categories now correctly show base name as category Same fixes as DECK frontend for consistent behavior. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1652 lines
61 KiB
HTML
1652 lines
61 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>hst</title>
|
||
<meta name="description" content="DECK Tag Management System">
|
||
|
||
<!--
|
||
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;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<!-- ══════════════════════════════════════════════════════════════════════════
|
||
HTML STRUCTURE
|
||
══════════════════════════════════════════════════════════════════════════ -->
|
||
<div class="app">
|
||
|
||
<!-- TOPBAR -->
|
||
<header class="topbar" role="banner">
|
||
<div class="topbar__left">
|
||
<span class="logo">HST</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>
|
||
|
||
<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">×</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>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
|
||
*/
|
||
|
||
// =============================================================================
|
||
// 1. CONFIG
|
||
// =============================================================================
|
||
const CONFIG = {
|
||
API_BASE: "/api",
|
||
IMG_BASE: "https://tzrtech.org",
|
||
|
||
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 }
|
||
},
|
||
|
||
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" }
|
||
},
|
||
|
||
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: [] });
|
||
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">×</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", () => App.init());
|
||
</script>
|
||
</body>
|
||
</html>
|