Add pending apps and frontend components
- apps/captain-mobile: Mobile API service - apps/flow-ui: Flow UI application - apps/mindlink: Mindlink application - apps/storage: Storage API and workers - apps/tzzr-cli: TZZR CLI tool - deck-frontend/backups: Historical TypeScript versions - hst-frontend: Standalone HST frontend Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
103
hst-frontend/css/components.css
Normal file
103
hst-frontend/css/components.css
Normal file
@@ -0,0 +1,103 @@
|
||||
/* === TOAST === */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 14px 28px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
/* === MODAL === */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
width: 90%;
|
||||
max-width: 620px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-header {
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-title { font-size: 1.15em; color: var(--text); font-weight: 600; }
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.5em;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
.modal-body { padding: 22px; overflow-y: auto; max-height: calc(80vh - 65px); }
|
||||
.api-item { margin-bottom: 18px; }
|
||||
.api-endpoint {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: var(--accent);
|
||||
background: var(--bg-card);
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.api-desc { font-size: 0.85em; color: var(--text-muted); margin-top: 8px; }
|
||||
|
||||
/* === EMPTY STATE === */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
gap: 16px;
|
||||
}
|
||||
.empty-state-icon { font-size: 4em; opacity: 0.4; }
|
||||
|
||||
/* === LOADING === */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
gap: 14px;
|
||||
font-size: 1em;
|
||||
}
|
||||
.loading::after {
|
||||
content: "";
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
99
hst-frontend/css/detail.css
Normal file
99
hst-frontend/css/detail.css
Normal file
@@ -0,0 +1,99 @@
|
||||
/* === RIGHT PANEL (DETAIL) === */
|
||||
.right-panel {
|
||||
width: 340px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.right-panel.open { display: block; }
|
||||
|
||||
.detail-header {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
background: linear-gradient(145deg, var(--bg-card) 0%, var(--bg) 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
.detail-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 5em;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
opacity: 0.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.detail-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.detail-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
z-index: 5;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.detail-close:hover { background: rgba(0,0,0,0.9); }
|
||||
|
||||
.detail-body { padding: 20px; }
|
||||
.detail-ref {
|
||||
font-size: 1.2em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.detail-mrf {
|
||||
font-size: 0.7em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-card);
|
||||
border-radius: 6px;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.detail-mrf:hover { color: var(--accent); }
|
||||
.detail-name { font-size: 1.3em; color: var(--text); margin-top: 16px; font-weight: 500; }
|
||||
.detail-desc { font-size: 0.9em; color: var(--text-muted); margin-top: 12px; line-height: 1.7; }
|
||||
|
||||
.detail-section { margin-top: 24px; }
|
||||
.detail-section-title {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tag-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.tag-chip {
|
||||
padding: 7px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.tag-chip:hover { border-color: var(--accent); color: var(--text); }
|
||||
76
hst-frontend/css/graph.css
Normal file
76
hst-frontend/css/graph.css
Normal file
@@ -0,0 +1,76 @@
|
||||
/* === GRAPH VIEW === */
|
||||
.graph-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: none;
|
||||
}
|
||||
#graph-svg { width: 100%; height: 100%; display: block; }
|
||||
.node { cursor: pointer; }
|
||||
.node text { fill: var(--text-muted); pointer-events: none; font-size: 11px; }
|
||||
.node.selected circle { stroke: var(--accent); stroke-width: 4; }
|
||||
.link { stroke-opacity: 0.5; }
|
||||
|
||||
.graph-controls { position: absolute; top: 16px; right: 16px; display: flex; gap: 6px; }
|
||||
.graph-sidebar {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
width: 210px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
font-size: 0.8em;
|
||||
max-height: calc(100% - 32px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.graph-sidebar h4 {
|
||||
color: var(--text-muted);
|
||||
margin: 12px 0 8px;
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.graph-sidebar h4:first-child { margin-top: 0; }
|
||||
|
||||
.graph-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; }
|
||||
.graph-stat {
|
||||
background: var(--bg-card);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.graph-stat-val { font-size: 1.4em; font-weight: 700; color: var(--accent); }
|
||||
.graph-stat-label { font-size: 0.7em; color: var(--text-muted); margin-top: 3px; }
|
||||
|
||||
.graph-filters { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.graph-filter {
|
||||
padding: 5px 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.graph-filter:hover { border-color: var(--accent); }
|
||||
.graph-filter.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.graph-filter .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
.graph-slider { width: 100%; margin: 6px 0; accent-color: var(--accent); }
|
||||
.graph-legend {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; margin: 4px 0; color: var(--text-muted); }
|
||||
.legend-color { width: 12px; height: 12px; border-radius: 50%; margin-right: 10px; flex-shrink: 0; }
|
||||
102
hst-frontend/css/grid.css
Normal file
102
hst-frontend/css/grid.css
Normal file
@@ -0,0 +1,102 @@
|
||||
/* === GRID VIEW === */
|
||||
.grid-view {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: var(--card-width);
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
background: var(--bg-card);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
.card.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(124, 138, 255, 0.4);
|
||||
}
|
||||
|
||||
.card-checkbox {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
border: 2px solid var(--border);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.card-checkbox.visible { display: flex; }
|
||||
.card-checkbox.checked { background: var(--accent); border-color: var(--accent); }
|
||||
.card-checkbox.checked::after { content: "\2713"; color: #fff; font-size: 14px; font-weight: bold; }
|
||||
|
||||
.card-image {
|
||||
width: var(--card-width);
|
||||
height: var(--card-img-height);
|
||||
background: linear-gradient(145deg, #1a1a24 0%, #0a0a0f 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5em;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
opacity: 0.5;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-body { padding: 12px; }
|
||||
.card-ref {
|
||||
font-size: 0.75em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.card-name {
|
||||
font-size: 0.85em;
|
||||
color: var(--text);
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.3;
|
||||
}
|
||||
181
hst-frontend/css/main.css
Normal file
181
hst-frontend/css/main.css
Normal file
@@ -0,0 +1,181 @@
|
||||
/* === RESET & VARIABLES === */
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--bg-card: #1a1a24;
|
||||
--border: #2a2a3a;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--accent: #7c8aff;
|
||||
--card-width: 176px;
|
||||
--card-img-height: 176px;
|
||||
}
|
||||
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-secondary); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #444; }
|
||||
|
||||
/* === TOPBAR === */
|
||||
.topbar {
|
||||
height: 50px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.topbar-left { display: flex; align-items: center; gap: 10px; }
|
||||
.topbar-center { flex: 1; display: flex; justify-content: center; }
|
||||
.topbar-right { display: flex; align-items: center; gap: 10px; }
|
||||
.logo { font-weight: 700; font-size: 1.2em; color: var(--accent); letter-spacing: 1px; }
|
||||
|
||||
/* === BUTTONS === */
|
||||
.btn {
|
||||
padding: 7px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-sm { padding: 5px 10px; font-size: 0.75em; }
|
||||
.sel-count { font-size: 0.7em; margin-left: 4px; opacity: 0.8; }
|
||||
|
||||
.search-box {
|
||||
width: 300px;
|
||||
padding: 9px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.search-box:focus { outline: none; border-color: var(--accent); }
|
||||
.search-box::placeholder { color: var(--text-muted); }
|
||||
|
||||
.base-selector { display: flex; gap: 2px; background: var(--bg-card); border-radius: 6px; padding: 3px; }
|
||||
.base-btn {
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.base-btn:hover { color: var(--text); }
|
||||
.base-btn.active { background: var(--accent); color: #fff; }
|
||||
|
||||
/* === GROUPS BAR === */
|
||||
.groups-bar {
|
||||
height: 44px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.group-btn {
|
||||
padding: 6px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.75em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.group-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
.group-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
/* === MAIN LAYOUT === */
|
||||
.main-layout { display: flex; height: calc(100vh - 94px); }
|
||||
|
||||
/* === LEFT PANEL === */
|
||||
.left-panel {
|
||||
width: 84px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 10px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lib-icon {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
margin: 6px auto;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
transition: all 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lib-icon:hover { border-color: var(--accent); }
|
||||
.lib-icon.active { border-color: var(--accent); background: rgba(124, 138, 255, 0.15); }
|
||||
.lib-icon img { width: 42px; height: 42px; object-fit: cover; border-radius: 6px; }
|
||||
.lib-icon span {
|
||||
font-size: 0.6em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
max-width: 62px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lib-icon.active span { color: var(--accent); }
|
||||
|
||||
/* === CENTER PANEL === */
|
||||
.center-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
||||
|
||||
.view-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.view-tab {
|
||||
padding: 7px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.view-tab:hover { color: var(--text); background: var(--bg-card); }
|
||||
.view-tab.active { background: var(--accent); color: #fff; }
|
||||
|
||||
.view-container { flex: 1; overflow: hidden; position: relative; }
|
||||
55
hst-frontend/css/tree.css
Normal file
55
hst-frontend/css/tree.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/* === TREE VIEW === */
|
||||
.tree-view {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
display: none;
|
||||
}
|
||||
.tree-root { margin-bottom: 12px; }
|
||||
.tree-node { margin-left: 28px; }
|
||||
.tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
margin: 3px 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.tree-item:hover { background: var(--bg-card); }
|
||||
.tree-item.selected { background: rgba(124,138,255,0.15); }
|
||||
.tree-toggle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tree-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
margin-right: 12px;
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tree-checkbox.visible { display: block; }
|
||||
.tree-checkbox.checked { background: var(--accent); border-color: var(--accent); }
|
||||
.tree-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
margin-right: 12px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.tree-name { font-size: 0.9em; }
|
||||
.tree-children { display: none; }
|
||||
.tree-children.open { display: block; }
|
||||
139
hst-frontend/index.html
Normal file
139
hst-frontend/index.html
Normal file
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<title>DECK</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- External -->
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
<link rel="stylesheet" href="css/grid.css">
|
||||
<link rel="stylesheet" href="css/tree.css">
|
||||
<link rel="stylesheet" href="css/graph.css">
|
||||
<link rel="stylesheet" href="css/detail.css">
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- TOPBAR -->
|
||||
<div class="topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="logo">DECK</span>
|
||||
<select id="lang-select" class="btn btn-sm">
|
||||
<option value="es">ES</option>
|
||||
<option value="en">EN</option>
|
||||
<option value="ch">CH</option>
|
||||
</select>
|
||||
<button class="btn btn-sm" id="btn-api">API</button>
|
||||
<button class="btn btn-sm" id="btn-sel">SEL</button>
|
||||
<button class="btn btn-sm" id="btn-get">GET<span class="sel-count" id="sel-count"></span></button>
|
||||
</div>
|
||||
<div class="topbar-center">
|
||||
<input type="text" class="search-box" id="search" placeholder="Buscar tags...">
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="base-selector">
|
||||
<button class="base-btn active" data-base="hst">HST</button>
|
||||
<button class="base-btn" data-base="flg">FLG</button>
|
||||
<button class="base-btn" data-base="itm">ITM</button>
|
||||
<button class="base-btn" data-base="loc">LOC</button>
|
||||
<button class="base-btn" data-base="ply">PLY</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GROUPS BAR -->
|
||||
<div class="groups-bar" id="groups-bar">
|
||||
<button class="group-btn active" data-group="all">Todos</button>
|
||||
</div>
|
||||
|
||||
<!-- MAIN LAYOUT -->
|
||||
<div class="main-layout">
|
||||
<!-- LEFT PANEL - Libraries -->
|
||||
<div class="left-panel" id="left-panel">
|
||||
<div class="lib-icon active" data-lib="all" title="Todos"><span>ALL</span></div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER PANEL -->
|
||||
<div class="center-panel">
|
||||
<div class="view-tabs">
|
||||
<button class="view-tab active" data-view="grid">Biblioteca</button>
|
||||
<button class="view-tab" data-view="tree">Arbol</button>
|
||||
<button class="view-tab" data-view="graph">Grafo</button>
|
||||
</div>
|
||||
<div class="view-container">
|
||||
<div class="grid-view" id="grid-view"></div>
|
||||
<div class="tree-view" id="tree-view"></div>
|
||||
<div class="graph-view" id="graph-view">
|
||||
<div class="graph-sidebar" id="graph-sidebar"></div>
|
||||
<svg id="graph-svg"></svg>
|
||||
<div class="graph-controls">
|
||||
<button class="btn btn-sm" id="graph-fit">Ajustar</button>
|
||||
<button class="btn btn-sm" id="graph-zin">+</button>
|
||||
<button class="btn btn-sm" id="graph-zout">-</button>
|
||||
</div>
|
||||
<div class="graph-legend" id="graph-legend"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT PANEL - Detail -->
|
||||
<div class="right-panel" id="right-panel">
|
||||
<div class="detail-header" id="detail-header">
|
||||
<div class="detail-placeholder" id="detail-placeholder"></div>
|
||||
<button class="detail-close" id="detail-close">x</button>
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<div class="detail-ref" id="detail-ref"></div>
|
||||
<div class="detail-mrf" id="detail-mrf" title="Click para copiar"></div>
|
||||
<div class="detail-name" id="detail-name"></div>
|
||||
<div class="detail-desc" id="detail-desc"></div>
|
||||
<div class="detail-section" id="children-section" style="display:none">
|
||||
<div class="detail-section-title">Hijos</div>
|
||||
<div class="tag-list" id="children-list"></div>
|
||||
</div>
|
||||
<div class="detail-section" id="related-section" style="display:none">
|
||||
<div class="detail-section-title">Relacionados</div>
|
||||
<div class="tag-list" id="related-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOAST -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<!-- API MODAL -->
|
||||
<div class="modal-overlay" id="api-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">API Endpoints</span>
|
||||
<button class="modal-close" id="api-modal-close">x</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="api-item"><div class="api-endpoint">GET /api/{base}</div><div class="api-desc">Lista tags (base: hst, flg, itm, loc, ply)</div></div>
|
||||
<div class="api-item"><div class="api-endpoint">GET /api/api_groups</div><div class="api-desc">Lista grupos disponibles</div></div>
|
||||
<div class="api-item"><div class="api-endpoint">GET /api/api_library_list</div><div class="api-desc">Lista bibliotecas</div></div>
|
||||
<div class="api-item"><div class="api-endpoint">GET /api/graph_hst</div><div class="api-desc">Relaciones del grafo</div></div>
|
||||
<div class="api-item"><div class="api-endpoint">GET /api/tree_hst</div><div class="api-desc">Jerarquias</div></div>
|
||||
<div class="api-item"><div class="api-endpoint">POST /api/rpc/api_children</div><div class="api-desc">{"parent_mrf": "xxx"}</div></div>
|
||||
<div class="api-item"><div class="api-endpoint">POST /api/rpc/api_related</div><div class="api-desc">{"tag_mrf": "xxx"}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript Modules (order matters) -->
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/state.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/helpers.js"></script>
|
||||
<script src="js/views/grid.js"></script>
|
||||
<script src="js/views/tree.js"></script>
|
||||
<script src="js/views/graph.js"></script>
|
||||
<script src="js/views/detail.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
81
hst-frontend/js/api.js
Normal file
81
hst-frontend/js/api.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// === API FUNCTIONS ===
|
||||
|
||||
async function fetchTags() {
|
||||
try {
|
||||
const r = await fetch(`${API}/${state.base}?order=ref.asc`);
|
||||
state.tags = r.ok ? await r.json() : [];
|
||||
} catch(e) {
|
||||
state.tags = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGroups() {
|
||||
try {
|
||||
const r = await fetch(`${API}/api_groups`);
|
||||
state.groups = r.ok ? await r.json() : [];
|
||||
} catch(e) {
|
||||
state.groups = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLibraries() {
|
||||
try {
|
||||
const r = await fetch(`${API}/api_library_list`);
|
||||
state.libraries = r.ok ? await r.json() : [];
|
||||
} catch(e) {
|
||||
state.libraries = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGraphEdges() {
|
||||
try {
|
||||
const r = await fetch(`${API}/graph_hst`);
|
||||
state.graphEdges = r.ok ? await r.json() : [];
|
||||
} catch(e) {
|
||||
state.graphEdges = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTreeEdges() {
|
||||
try {
|
||||
const r = await fetch(`${API}/tree_hst`);
|
||||
state.treeEdges = r.ok ? await r.json() : [];
|
||||
} catch(e) {
|
||||
state.treeEdges = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLibraryMembers(mrf) {
|
||||
try {
|
||||
const r = await fetch(`${API}/library_hst?mrf_library=eq.${mrf}`);
|
||||
return r.ok ? (await r.json()).map(d => d.mrf_tag) : [];
|
||||
} catch(e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChildren(mrf) {
|
||||
try {
|
||||
const r = await fetch(`${API}/rpc/api_children`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({parent_mrf: mrf})
|
||||
});
|
||||
return r.ok ? await r.json() : [];
|
||||
} catch(e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRelated(mrf) {
|
||||
try {
|
||||
const r = await fetch(`${API}/rpc/api_related`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({tag_mrf: mrf})
|
||||
});
|
||||
return r.ok ? await r.json() : [];
|
||||
} catch(e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
148
hst-frontend/js/app.js
Normal file
148
hst-frontend/js/app.js
Normal file
@@ -0,0 +1,148 @@
|
||||
// === APPLICATION INIT ===
|
||||
|
||||
function parseHash() {
|
||||
const h = window.location.hash.replace(/^#\/?/, "").replace(/\/?$/, "").split("/").filter(Boolean);
|
||||
if (h[0] && ["hst","flg","itm","loc","ply"].includes(h[0].toLowerCase())) {
|
||||
state.base = h[0].toLowerCase();
|
||||
}
|
||||
if (h[1] && ["grid","tree","graph"].includes(h[1].toLowerCase())) {
|
||||
state.view = h[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function updateHash() {
|
||||
const p = [state.base];
|
||||
if (state.view !== "grid") p.push(state.view);
|
||||
window.location.hash = "/" + p.join("/") + "/";
|
||||
}
|
||||
|
||||
async function init() {
|
||||
parseHash();
|
||||
|
||||
// Update UI to match state
|
||||
document.querySelectorAll(".base-btn").forEach(b =>
|
||||
b.classList.toggle("active", b.dataset.base === state.base)
|
||||
);
|
||||
document.querySelectorAll(".view-tab").forEach(t =>
|
||||
t.classList.toggle("active", t.dataset.view === state.view)
|
||||
);
|
||||
|
||||
// Show loading
|
||||
document.getElementById("grid-view").innerHTML = '<div class="loading">Cargando</div>';
|
||||
|
||||
// Fetch initial data
|
||||
await Promise.all([fetchTags(), fetchGroups(), fetchLibraries()]);
|
||||
|
||||
// Render UI
|
||||
renderGroups();
|
||||
renderLibraries();
|
||||
renderView();
|
||||
}
|
||||
|
||||
// === EVENT BINDINGS ===
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Base selector
|
||||
document.querySelectorAll(".base-btn").forEach(b => b.onclick = async () => {
|
||||
document.querySelectorAll(".base-btn").forEach(x => x.classList.remove("active"));
|
||||
b.classList.add("active");
|
||||
state.base = b.dataset.base;
|
||||
|
||||
// Reset state
|
||||
state.group = "all";
|
||||
state.library = "all";
|
||||
state.libraryMembers.clear();
|
||||
state.search = "";
|
||||
state.graphEdges = [];
|
||||
state.treeEdges = [];
|
||||
clearSelection();
|
||||
closeDetail();
|
||||
document.getElementById("search").value = "";
|
||||
updateHash();
|
||||
|
||||
// Reload
|
||||
document.getElementById("grid-view").innerHTML = '<div class="loading">Cargando</div>';
|
||||
await fetchTags();
|
||||
await fetchGroups();
|
||||
renderGroups();
|
||||
renderView();
|
||||
});
|
||||
|
||||
// View tabs
|
||||
document.querySelectorAll(".view-tab").forEach(t => t.onclick = () => {
|
||||
document.querySelectorAll(".view-tab").forEach(x => x.classList.remove("active"));
|
||||
t.classList.add("active");
|
||||
state.view = t.dataset.view;
|
||||
updateHash();
|
||||
closeDetail();
|
||||
renderView();
|
||||
});
|
||||
|
||||
// Search
|
||||
let st;
|
||||
document.getElementById("search").oninput = e => {
|
||||
clearTimeout(st);
|
||||
st = setTimeout(() => {
|
||||
state.search = e.target.value;
|
||||
renderView();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Language selector
|
||||
document.getElementById("lang-select").addEventListener("change", function(e) {
|
||||
state.lang = this.value;
|
||||
renderView();
|
||||
if (state.selectedTag) showDetail(state.selectedTag.mrf);
|
||||
});
|
||||
|
||||
// Selection mode
|
||||
document.getElementById("btn-sel").onclick = () => {
|
||||
state.selectionMode = !state.selectionMode;
|
||||
document.getElementById("btn-sel").classList.toggle("active", state.selectionMode);
|
||||
if (!state.selectionMode) {
|
||||
state.selected.clear();
|
||||
updateSelCount();
|
||||
}
|
||||
renderView();
|
||||
};
|
||||
|
||||
// Get selected
|
||||
document.getElementById("btn-get").onclick = () => {
|
||||
if (!state.selected.size) return toast("No hay seleccionados");
|
||||
navigator.clipboard.writeText([...state.selected].join("\n"))
|
||||
.then(() => toast(`Copiados ${state.selected.size} mrfs`));
|
||||
};
|
||||
|
||||
// API modal
|
||||
document.getElementById("btn-api").onclick = () =>
|
||||
document.getElementById("api-modal").classList.add("open");
|
||||
document.getElementById("api-modal-close").onclick = () =>
|
||||
document.getElementById("api-modal").classList.remove("open");
|
||||
document.getElementById("api-modal").onclick = e => {
|
||||
if (e.target.id === "api-modal") e.target.classList.remove("open");
|
||||
};
|
||||
|
||||
// Hash change
|
||||
window.onhashchange = () => {
|
||||
parseHash();
|
||||
init();
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.onkeydown = e => {
|
||||
if (e.key === "Escape") {
|
||||
closeDetail();
|
||||
document.getElementById("api-modal").classList.remove("open");
|
||||
if (state.selectionMode) {
|
||||
clearSelection();
|
||||
renderView();
|
||||
}
|
||||
}
|
||||
if (e.key === "/" && document.activeElement.tagName !== "INPUT") {
|
||||
e.preventDefault();
|
||||
document.getElementById("search").focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Start app
|
||||
init();
|
||||
});
|
||||
25
hst-frontend/js/config.js
Normal file
25
hst-frontend/js/config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// === CONFIGURATION ===
|
||||
|
||||
const CATS = {
|
||||
hst: {name: "Hashtags", color: "#7c8aff"},
|
||||
spe: {name: "Specs", color: "#FF9800"},
|
||||
vue: {name: "Values", color: "#00BCD4"},
|
||||
vsn: {name: "Visions", color: "#E91E63"},
|
||||
msn: {name: "Missions", color: "#9C27B0"},
|
||||
flg: {name: "Flags", color: "#4CAF50"}
|
||||
};
|
||||
|
||||
const EDGE_COLORS = {
|
||||
relation: "#8BC34A",
|
||||
specialization: "#9C27B0",
|
||||
mirror: "#607D8B",
|
||||
dependency: "#2196F3",
|
||||
sequence: "#4CAF50",
|
||||
composition: "#FF9800",
|
||||
hierarchy: "#E91E63",
|
||||
library: "#00BCD4",
|
||||
contextual: "#FFC107",
|
||||
association: "#795548"
|
||||
};
|
||||
|
||||
const API = "/api";
|
||||
77
hst-frontend/js/helpers.js
Normal file
77
hst-frontend/js/helpers.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// === HELPER FUNCTIONS ===
|
||||
|
||||
function getName(t) {
|
||||
return state.lang === "en"
|
||||
? (t.name_en || t.name_es || t.ref)
|
||||
: state.lang === "ch"
|
||||
? (t.name_ch || t.name_en || t.name_es || t.ref)
|
||||
: (t.name_es || t.name_en || t.ref);
|
||||
}
|
||||
|
||||
function getImg(t) {
|
||||
return t.img_thumb_url || t.img_url || "";
|
||||
}
|
||||
|
||||
function getFullImg(t) {
|
||||
return t.img_url || t.img_thumb_url || "";
|
||||
}
|
||||
|
||||
function filterTags() {
|
||||
let f = [...state.tags];
|
||||
|
||||
// Search filter
|
||||
if (state.search) {
|
||||
const q = state.search.toLowerCase();
|
||||
f = f.filter(t =>
|
||||
(t.ref||"").toLowerCase().includes(q) ||
|
||||
(t.name_es||"").toLowerCase().includes(q) ||
|
||||
(t.name_en||"").toLowerCase().includes(q) ||
|
||||
(t.mrf||"").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Group filter
|
||||
if (state.group !== "all") {
|
||||
f = f.filter(t => t.set_hst === state.group);
|
||||
}
|
||||
|
||||
// Library filter
|
||||
if (state.library !== "all" && state.libraryMembers.size) {
|
||||
f = f.filter(t => state.libraryMembers.has(t.mrf));
|
||||
}
|
||||
|
||||
return f;
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
const t = document.getElementById("toast");
|
||||
t.textContent = msg;
|
||||
t.classList.add("show");
|
||||
setTimeout(() => t.classList.remove("show"), 2500);
|
||||
}
|
||||
|
||||
function copyMrf(mrf) {
|
||||
navigator.clipboard.writeText(mrf).then(() => toast(`Copiado: ${mrf.slice(0,16)}...`));
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
document.getElementById("right-panel").classList.remove("open");
|
||||
state.selectedTag = null;
|
||||
}
|
||||
|
||||
function toggleSel(mrf) {
|
||||
state.selected.has(mrf) ? state.selected.delete(mrf) : state.selected.add(mrf);
|
||||
updateSelCount();
|
||||
renderView();
|
||||
}
|
||||
|
||||
function updateSelCount() {
|
||||
document.getElementById("sel-count").textContent = state.selected.size ? `(${state.selected.size})` : "";
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
state.selected.clear();
|
||||
state.selectionMode = false;
|
||||
document.getElementById("btn-sel").classList.remove("active");
|
||||
updateSelCount();
|
||||
}
|
||||
40
hst-frontend/js/state.js
Normal file
40
hst-frontend/js/state.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// === APPLICATION STATE ===
|
||||
|
||||
const state = {
|
||||
// Current selections
|
||||
base: "hst",
|
||||
lang: "es",
|
||||
view: "grid",
|
||||
search: "",
|
||||
group: "all",
|
||||
library: "all",
|
||||
|
||||
// Library filter
|
||||
libraryMembers: new Set(),
|
||||
|
||||
// Selection mode
|
||||
selectionMode: false,
|
||||
selected: new Set(),
|
||||
selectedTag: null,
|
||||
|
||||
// Data
|
||||
tags: [],
|
||||
groups: [],
|
||||
libraries: [],
|
||||
graphEdges: [],
|
||||
treeEdges: [],
|
||||
|
||||
// Graph filters
|
||||
graphFilters: {
|
||||
cats: new Set(["hst"]),
|
||||
edges: new Set(Object.keys(EDGE_COLORS))
|
||||
},
|
||||
|
||||
// Graph settings
|
||||
graphSettings: {
|
||||
nodeSize: 20,
|
||||
linkDist: 80,
|
||||
showImg: true,
|
||||
showLbl: true
|
||||
}
|
||||
};
|
||||
68
hst-frontend/js/ui.js
Normal file
68
hst-frontend/js/ui.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// === UI RENDER FUNCTIONS ===
|
||||
|
||||
function renderGroups() {
|
||||
const el = document.getElementById("groups-bar");
|
||||
|
||||
// Count tags per group
|
||||
const gm = new Map();
|
||||
state.tags.forEach(t => {
|
||||
if (t.set_hst) {
|
||||
if (!gm.has(t.set_hst)) gm.set(t.set_hst, 0);
|
||||
gm.set(t.set_hst, gm.get(t.set_hst) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const groups = [...gm.entries()].sort((a,b) => b[1] - a[1]);
|
||||
|
||||
el.innerHTML = `<button class="group-btn ${state.group === "all" ? "active" : ""}" data-group="all">Todos (${state.tags.length})</button>` +
|
||||
groups.slice(0, 20).map(([mrf, cnt]) => {
|
||||
const info = state.groups.find(g => g.mrf === mrf);
|
||||
const name = info ? (info.name_es || info.ref) : mrf.slice(0, 6);
|
||||
return `<button class="group-btn ${state.group === mrf ? "active" : ""}" data-group="${mrf}">${name} (${cnt})</button>`;
|
||||
}).join("");
|
||||
|
||||
el.querySelectorAll(".group-btn").forEach(b => {
|
||||
b.onclick = () => {
|
||||
state.group = b.dataset.group;
|
||||
renderGroups();
|
||||
renderView();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderLibraries() {
|
||||
const el = document.getElementById("left-panel");
|
||||
|
||||
el.innerHTML = `<div class="lib-icon ${state.library === "all" ? "active" : ""}" data-lib="all"><span>ALL</span></div>` +
|
||||
state.libraries.map(lib => {
|
||||
const icon = lib.img_thumb_url || lib.icon_url || "";
|
||||
const name = lib.name || lib.name_es || lib.ref || lib.mrf.slice(0, 6);
|
||||
return `<div class="lib-icon ${state.library === lib.mrf ? "active" : ""}" data-lib="${lib.mrf}" title="${name}">
|
||||
${icon ? `<img src="${icon}" alt="">` : ""}
|
||||
<span>${name.slice(0, 8)}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
el.querySelectorAll(".lib-icon").forEach(i => {
|
||||
i.onclick = async () => {
|
||||
state.library = i.dataset.lib;
|
||||
state.libraryMembers = state.library !== "all"
|
||||
? new Set(await fetchLibraryMembers(state.library))
|
||||
: new Set();
|
||||
renderLibraries();
|
||||
renderView();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderView() {
|
||||
// Toggle view visibility
|
||||
document.getElementById("grid-view").style.display = state.view === "grid" ? "flex" : "none";
|
||||
document.getElementById("tree-view").style.display = state.view === "tree" ? "block" : "none";
|
||||
document.getElementById("graph-view").style.display = state.view === "graph" ? "block" : "none";
|
||||
|
||||
// Render active view
|
||||
if (state.view === "grid") renderGrid();
|
||||
else if (state.view === "tree") renderTree();
|
||||
else if (state.view === "graph") initGraph();
|
||||
}
|
||||
72
hst-frontend/js/views/detail.js
Normal file
72
hst-frontend/js/views/detail.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// === DETAIL PANEL ===
|
||||
|
||||
async function showDetail(mrf) {
|
||||
const tag = state.tags.find(t => t.mrf === mrf);
|
||||
if (!tag) return;
|
||||
|
||||
state.selectedTag = tag;
|
||||
document.getElementById("right-panel").classList.add("open");
|
||||
|
||||
const hdr = document.getElementById("detail-header");
|
||||
const img = getFullImg(tag);
|
||||
const ref = (tag.ref || "").toUpperCase();
|
||||
|
||||
// Set placeholder
|
||||
document.getElementById("detail-placeholder").textContent = ref.slice(0, 2);
|
||||
|
||||
// Remove existing image
|
||||
hdr.querySelector("img")?.remove();
|
||||
|
||||
// Add image if exists
|
||||
if (img) {
|
||||
const imgEl = document.createElement("img");
|
||||
imgEl.className = "detail-img";
|
||||
imgEl.src = img;
|
||||
imgEl.alt = ref;
|
||||
hdr.insertBefore(imgEl, hdr.firstChild);
|
||||
}
|
||||
|
||||
// Bind close
|
||||
document.getElementById("detail-close").onclick = closeDetail;
|
||||
|
||||
// Fill basic info
|
||||
document.getElementById("detail-ref").textContent = ref;
|
||||
document.getElementById("detail-mrf").textContent = tag.mrf || "";
|
||||
document.getElementById("detail-mrf").onclick = () => copyMrf(tag.mrf);
|
||||
document.getElementById("detail-name").textContent = getName(tag);
|
||||
document.getElementById("detail-desc").textContent = tag.txt || tag.alias || "";
|
||||
|
||||
// Fetch and render children
|
||||
const children = await fetchChildren(mrf);
|
||||
const chSec = document.getElementById("children-section");
|
||||
const chList = document.getElementById("children-list");
|
||||
|
||||
if (children.length) {
|
||||
chSec.style.display = "block";
|
||||
chList.innerHTML = children.map(c =>
|
||||
`<span class="tag-chip" data-mrf="${c.mrf}">${c.ref || c.mrf.slice(0,8)}</span>`
|
||||
).join("");
|
||||
chList.querySelectorAll(".tag-chip").forEach(ch =>
|
||||
ch.onclick = () => showDetail(ch.dataset.mrf)
|
||||
);
|
||||
} else {
|
||||
chSec.style.display = "none";
|
||||
}
|
||||
|
||||
// Fetch and render related
|
||||
const related = await fetchRelated(mrf);
|
||||
const relSec = document.getElementById("related-section");
|
||||
const relList = document.getElementById("related-list");
|
||||
|
||||
if (related.length) {
|
||||
relSec.style.display = "block";
|
||||
relList.innerHTML = related.map(r =>
|
||||
`<span class="tag-chip" data-mrf="${r.mrf}" title="${r.edge_type}">${r.ref || r.mrf.slice(0,8)}</span>`
|
||||
).join("");
|
||||
relList.querySelectorAll(".tag-chip").forEach(ch =>
|
||||
ch.onclick = () => showDetail(ch.dataset.mrf)
|
||||
);
|
||||
} else {
|
||||
relSec.style.display = "none";
|
||||
}
|
||||
}
|
||||
264
hst-frontend/js/views/graph.js
Normal file
264
hst-frontend/js/views/graph.js
Normal file
@@ -0,0 +1,264 @@
|
||||
// === GRAPH VIEW ===
|
||||
|
||||
let gSvg, gG, gZoom, gSim;
|
||||
|
||||
function renderGraphSidebar() {
|
||||
const el = document.getElementById("graph-sidebar");
|
||||
const nc = filterTags().length;
|
||||
const ec = state.graphEdges.length + state.treeEdges.length;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="graph-stats">
|
||||
<div class="graph-stat">
|
||||
<div class="graph-stat-val">${nc}</div>
|
||||
<div class="graph-stat-label">Nodos</div>
|
||||
</div>
|
||||
<div class="graph-stat">
|
||||
<div class="graph-stat-val">${ec}</div>
|
||||
<div class="graph-stat-label">Edges</div>
|
||||
</div>
|
||||
</div>
|
||||
<h4>Categorias</h4>
|
||||
<div class="graph-filters">
|
||||
${Object.entries(CATS).map(([k,v]) =>
|
||||
`<div class="graph-filter ${state.graphFilters.cats.has(k) ? "active" : ""}" data-cat="${k}">
|
||||
<span class="dot" style="background:${v.color}"></span>${v.name}
|
||||
</div>`
|
||||
).join("")}
|
||||
</div>
|
||||
<h4>Relaciones</h4>
|
||||
<div class="graph-filters">
|
||||
${Object.entries(EDGE_COLORS).map(([k,v]) =>
|
||||
`<div class="graph-filter ${state.graphFilters.edges.has(k) ? "active" : ""}" data-edge="${k}">
|
||||
<span class="dot" style="background:${v}"></span>${k}
|
||||
</div>`
|
||||
).join("")}
|
||||
</div>
|
||||
<h4>Visualizacion</h4>
|
||||
<div style="margin:8px 0">
|
||||
<label><input type="checkbox" id="graph-show-img" ${state.graphSettings.showImg ? "checked" : ""}> Imagenes</label>
|
||||
</div>
|
||||
<div style="margin:8px 0">
|
||||
<label><input type="checkbox" id="graph-show-lbl" ${state.graphSettings.showLbl ? "checked" : ""}> Etiquetas</label>
|
||||
</div>
|
||||
<div style="margin:12px 0">
|
||||
<div style="color:var(--text-muted);margin-bottom:6px">Nodo: ${state.graphSettings.nodeSize}px</div>
|
||||
<input type="range" class="graph-slider" id="graph-node-size" min="10" max="50" value="${state.graphSettings.nodeSize}">
|
||||
</div>
|
||||
<div style="margin:12px 0">
|
||||
<div style="color:var(--text-muted);margin-bottom:6px">Distancia: ${state.graphSettings.linkDist}px</div>
|
||||
<input type="range" class="graph-slider" id="graph-link-dist" min="40" max="200" value="${state.graphSettings.linkDist}">
|
||||
</div>`;
|
||||
|
||||
// Bind category filters
|
||||
el.querySelectorAll("[data-cat]").forEach(f => {
|
||||
f.onclick = () => {
|
||||
const c = f.dataset.cat;
|
||||
state.graphFilters.cats.has(c) ? state.graphFilters.cats.delete(c) : state.graphFilters.cats.add(c);
|
||||
initGraph();
|
||||
};
|
||||
});
|
||||
|
||||
// Bind edge filters
|
||||
el.querySelectorAll("[data-edge]").forEach(f => {
|
||||
f.onclick = () => {
|
||||
const e = f.dataset.edge;
|
||||
state.graphFilters.edges.has(e) ? state.graphFilters.edges.delete(e) : state.graphFilters.edges.add(e);
|
||||
initGraph();
|
||||
};
|
||||
});
|
||||
|
||||
// Bind settings
|
||||
document.getElementById("graph-show-img").onchange = e => {
|
||||
state.graphSettings.showImg = e.target.checked;
|
||||
updateGraphVisuals();
|
||||
};
|
||||
document.getElementById("graph-show-lbl").onchange = e => {
|
||||
state.graphSettings.showLbl = e.target.checked;
|
||||
updateGraphVisuals();
|
||||
};
|
||||
document.getElementById("graph-node-size").oninput = e => {
|
||||
state.graphSettings.nodeSize = +e.target.value;
|
||||
updateGraphVisuals();
|
||||
};
|
||||
document.getElementById("graph-link-dist").oninput = e => {
|
||||
state.graphSettings.linkDist = +e.target.value;
|
||||
if (gSim) {
|
||||
gSim.force("link").distance(state.graphSettings.linkDist);
|
||||
gSim.alpha(0.3).restart();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderGraphLegend() {
|
||||
document.getElementById("graph-legend").innerHTML = Object.entries(CATS)
|
||||
.filter(([k]) => state.graphFilters.cats.has(k))
|
||||
.map(([k,v]) => `<div class="legend-item"><div class="legend-color" style="background:${v.color}"></div>${v.name}</div>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function updateGraphVisuals() {
|
||||
if (!gG) return;
|
||||
const ns = state.graphSettings.nodeSize;
|
||||
|
||||
gG.selectAll(".node circle").attr("r", ns);
|
||||
gG.selectAll(".node image")
|
||||
.attr("x", -ns+5)
|
||||
.attr("y", -ns+5)
|
||||
.attr("width", (ns-5)*2)
|
||||
.attr("height", (ns-5)*2)
|
||||
.style("display", state.graphSettings.showImg ? "block" : "none");
|
||||
gG.selectAll(".node text")
|
||||
.attr("dx", ns+5)
|
||||
.style("display", state.graphSettings.showLbl ? "block" : "none");
|
||||
|
||||
renderGraphSidebar();
|
||||
}
|
||||
|
||||
async function initGraph() {
|
||||
const container = document.getElementById("graph-view");
|
||||
const svg = document.getElementById("graph-svg");
|
||||
const w = container.clientWidth - 230;
|
||||
const h = container.clientHeight;
|
||||
|
||||
if (gSim) gSim.stop();
|
||||
|
||||
gSvg = d3.select(svg).attr("width", w + 230).attr("height", h);
|
||||
gSvg.selectAll("*").remove();
|
||||
gG = gSvg.append("g").attr("transform", "translate(230, 0)");
|
||||
|
||||
gZoom = d3.zoom()
|
||||
.scaleExtent([0.05, 4])
|
||||
.on("zoom", e => gG.attr("transform", `translate(230, 0) ${e.transform}`));
|
||||
gSvg.call(gZoom);
|
||||
|
||||
renderGraphSidebar();
|
||||
renderGraphLegend();
|
||||
|
||||
// Fetch data if needed
|
||||
if (!state.graphEdges.length) await fetchGraphEdges();
|
||||
if (!state.treeEdges.length) await fetchTreeEdges();
|
||||
|
||||
// Build nodes
|
||||
const filtered = filterTags();
|
||||
const nodes = filtered.map(t => {
|
||||
const grupo = t.set_hst || "hst";
|
||||
const groupInfo = state.groups.find(g => g.mrf === grupo);
|
||||
const cat = groupInfo?.ref || "hst";
|
||||
return { id: t.mrf, ref: t.ref || "", name: getName(t), img: getImg(t), cat };
|
||||
}).filter(n => state.graphFilters.cats.has(n.cat) || state.graphFilters.cats.has("hst"));
|
||||
|
||||
if (!nodes.length) {
|
||||
gG.append("text")
|
||||
.attr("x", w/2)
|
||||
.attr("y", h/2)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#666")
|
||||
.text("Sin datos - activa categorias");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build edges
|
||||
const nodeIds = new Set(nodes.map(n => n.id));
|
||||
const edges = [];
|
||||
|
||||
state.graphEdges.forEach(e => {
|
||||
const t = e.edge_type || "relation";
|
||||
if (state.graphFilters.edges.has(t) && nodeIds.has(e.mrf_a) && nodeIds.has(e.mrf_b)) {
|
||||
edges.push({source: e.mrf_a, target: e.mrf_b, type: t, weight: e.weight || 0.5});
|
||||
}
|
||||
});
|
||||
|
||||
if (state.graphFilters.edges.has("hierarchy")) {
|
||||
state.treeEdges.forEach(e => {
|
||||
if (nodeIds.has(e.mrf_parent) && nodeIds.has(e.mrf_child)) {
|
||||
edges.push({source: e.mrf_parent, target: e.mrf_child, type: "hierarchy", weight: 0.8});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create simulation
|
||||
const ns = state.graphSettings.nodeSize;
|
||||
gSim = d3.forceSimulation(nodes)
|
||||
.force("link", d3.forceLink(edges).id(d => d.id).distance(state.graphSettings.linkDist))
|
||||
.force("charge", d3.forceManyBody().strength(-150))
|
||||
.force("center", d3.forceCenter(w/2, h/2))
|
||||
.force("collision", d3.forceCollide(ns + 4));
|
||||
|
||||
// Draw links
|
||||
const link = gG.append("g")
|
||||
.selectAll("line")
|
||||
.data(edges)
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("stroke", d => EDGE_COLORS[d.type] || "#333")
|
||||
.attr("stroke-width", d => Math.max(1.5, d.weight * 3))
|
||||
.attr("class", "link");
|
||||
|
||||
// Draw nodes
|
||||
const node = gG.append("g")
|
||||
.selectAll("g")
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", d => `node ${state.selected.has(d.id) ? "selected" : ""}`)
|
||||
.call(d3.drag()
|
||||
.on("start", (e,d) => {
|
||||
if (!e.active) gSim.alphaTarget(0.3).restart();
|
||||
d.fx = d.x; d.fy = d.y;
|
||||
})
|
||||
.on("drag", (e,d) => { d.fx = e.x; d.fy = e.y; })
|
||||
.on("end", (e,d) => {
|
||||
if (!e.active) gSim.alphaTarget(0);
|
||||
d.fx = null; d.fy = null;
|
||||
})
|
||||
);
|
||||
|
||||
node.append("circle")
|
||||
.attr("r", ns)
|
||||
.attr("fill", d => CATS[d.cat]?.color || "#7c8aff")
|
||||
.attr("stroke", d => state.selected.has(d.id) ? "var(--accent)" : "#1a1a24")
|
||||
.attr("stroke-width", d => state.selected.has(d.id) ? 4 : 2);
|
||||
|
||||
node.filter(d => d.img && state.graphSettings.showImg)
|
||||
.append("image")
|
||||
.attr("href", d => d.img)
|
||||
.attr("x", -ns+5)
|
||||
.attr("y", -ns+5)
|
||||
.attr("width", (ns-5)*2)
|
||||
.attr("height", (ns-5)*2)
|
||||
.attr("clip-path", "circle(50%)");
|
||||
|
||||
node.append("text")
|
||||
.attr("dx", ns+5)
|
||||
.attr("dy", 4)
|
||||
.text(d => d.ref)
|
||||
.style("display", state.graphSettings.showLbl ? "block" : "none");
|
||||
|
||||
node.on("click", (e,d) => state.selectionMode ? toggleSel(d.id) : showDetail(d.id));
|
||||
|
||||
// Tick handler
|
||||
gSim.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => d.source.x)
|
||||
.attr("y1", d => d.source.y)
|
||||
.attr("x2", d => d.target.x)
|
||||
.attr("y2", d => d.target.y);
|
||||
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
// Controls
|
||||
document.getElementById("graph-fit").onclick = () => {
|
||||
const b = gG.node().getBBox();
|
||||
if (b.width) {
|
||||
const s = Math.min((w-100)/b.width, (h-100)/b.height, 2);
|
||||
gSvg.transition().call(gZoom.transform,
|
||||
d3.zoomIdentity
|
||||
.translate(w/2-(b.x+b.width/2)*s+230, h/2-(b.y+b.height/2)*s)
|
||||
.scale(s)
|
||||
);
|
||||
}
|
||||
};
|
||||
document.getElementById("graph-zin").onclick = () => gSvg.transition().call(gZoom.scaleBy, 1.5);
|
||||
document.getElementById("graph-zout").onclick = () => gSvg.transition().call(gZoom.scaleBy, 0.67);
|
||||
}
|
||||
33
hst-frontend/js/views/grid.js
Normal file
33
hst-frontend/js/views/grid.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// === GRID VIEW ===
|
||||
|
||||
function renderGrid() {
|
||||
const el = document.getElementById("grid-view");
|
||||
const filtered = filterTags();
|
||||
|
||||
if (!filtered.length) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="empty-state-icon">:/</div><div>No se encontraron tags</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = filtered.map(tag => {
|
||||
const img = getImg(tag);
|
||||
const ref = (tag.ref || "").toUpperCase();
|
||||
const ph = ref.slice(0, 2);
|
||||
const sel = state.selected.has(tag.mrf);
|
||||
|
||||
return `<div class="card ${sel ? "selected" : ""}" data-mrf="${tag.mrf}">
|
||||
<div class="card-checkbox ${state.selectionMode ? "visible" : ""} ${sel ? "checked" : ""}"></div>
|
||||
<div class="card-image">
|
||||
${img ? `<img class="card-img" src="${img}" loading="lazy" alt="${ref}">` : `<div class="card-placeholder">${ph}</div>`}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-ref">${ref}</div>
|
||||
<div class="card-name">${getName(tag)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
el.querySelectorAll(".card").forEach(c => {
|
||||
c.onclick = () => state.selectionMode ? toggleSel(c.dataset.mrf) : showDetail(c.dataset.mrf);
|
||||
});
|
||||
}
|
||||
64
hst-frontend/js/views/tree.js
Normal file
64
hst-frontend/js/views/tree.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// === TREE VIEW ===
|
||||
|
||||
function renderTree() {
|
||||
const el = document.getElementById("tree-view");
|
||||
const filtered = filterTags();
|
||||
|
||||
if (!filtered.length) {
|
||||
el.innerHTML = '<div class="empty-state"><div>Sin datos</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group tags by set_hst
|
||||
const groups = new Map();
|
||||
filtered.forEach(t => {
|
||||
const g = t.set_hst || "other";
|
||||
if (!groups.has(g)) groups.set(g, []);
|
||||
groups.get(g).push(t);
|
||||
});
|
||||
|
||||
el.innerHTML = [...groups.entries()].map(([gid, tags]) => {
|
||||
const info = state.groups.find(g => g.mrf === gid);
|
||||
const name = info ? (info.name_es || info.ref) : gid === "other" ? "Sin grupo" : gid.slice(0, 10);
|
||||
|
||||
return `<div class="tree-root">
|
||||
<div class="tree-item" data-expand="${gid}">
|
||||
<span class="tree-toggle">+</span>
|
||||
<span class="tree-name" style="font-weight:600;color:var(--accent)">${name} (${tags.length})</span>
|
||||
</div>
|
||||
<div class="tree-children" id="tree-${gid}">
|
||||
${tags.map(t => {
|
||||
const sel = state.selected.has(t.mrf);
|
||||
const img = getImg(t);
|
||||
return `<div class="tree-node">
|
||||
<div class="tree-item ${sel ? "selected" : ""}" data-mrf="${t.mrf}">
|
||||
<span class="tree-checkbox ${state.selectionMode ? "visible" : ""} ${sel ? "checked" : ""}"></span>
|
||||
<span class="tree-toggle"></span>
|
||||
${img ? `<img class="tree-img" src="${img}" alt="">` : ""}
|
||||
<span class="tree-name">${t.ref} - ${getName(t)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
// Bind expand/collapse
|
||||
el.querySelectorAll(".tree-item[data-expand]").forEach(i => {
|
||||
i.onclick = () => {
|
||||
const ch = document.getElementById(`tree-${i.dataset.expand}`);
|
||||
if (ch) {
|
||||
ch.classList.toggle("open");
|
||||
i.querySelector(".tree-toggle").textContent = ch.classList.contains("open") ? "-" : "+";
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Bind tag click
|
||||
el.querySelectorAll(".tree-item[data-mrf]").forEach(i => {
|
||||
i.onclick = e => {
|
||||
e.stopPropagation();
|
||||
state.selectionMode ? toggleSel(i.dataset.mrf) : showDetail(i.dataset.mrf);
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user