DECK Frontend v4.4 - Tree from tree_* relational tables

- Added API.getTree() to query tree_{base} tables
- TreeView now builds real 1:N hierarchy from tree_* data
- Recursive rendering with proper parent-child relationships
- Library filter still works (filters tags before building tree)
- Updated CSS for hierarchical tree display with depth indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ARCHITECT
2026-01-16 18:53:37 +00:00
parent 0bd1d6fbff
commit 171a356b25

View File

@@ -7,13 +7,13 @@
<meta name="description" content="DECK Tag Management System"> <meta name="description" content="DECK Tag Management System">
<!-- <!--
DECK FRONTEND v4.3 - Portable single-file DECK FRONTEND v4.4 - Tree from tree_* tables
Extract: ./extract.sh deck.html [output_dir] Extract: ./extract.sh deck.html [output_dir]
--> -->
<style> <style>
/* ============================================================================= /* =============================================================================
* DECK STYLES v4.3 * DECK STYLES v4.4
* ============================================================================= */ * ============================================================================= */
/* ----------------------------------------------------------------------------- /* -----------------------------------------------------------------------------
@@ -444,31 +444,21 @@ body {
} }
/* Tree View */ /* Tree View */
.content-area--tree { display: flex; flex-direction: column; gap: 8px; } .content-area--tree { display: flex; flex-direction: column; gap: 2px; }
.tree-group { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--border-radius-lg); overflow: hidden; } .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-header { .tree-toggle { width: 20px; font-weight: bold; color: var(--accent); text-align: center; user-select: none; cursor: pointer; flex-shrink: 0; }
padding: 12px 16px; .tree-toggle--empty { visibility: hidden; }
display: flex; .tree-count { color: var(--text-muted); font-size: 0.75em; margin-left: auto; }
align-items: center;
gap: 10px;
cursor: pointer;
transition: background var(--transition-fast);
}
.tree-header:hover { background: var(--bg-hover); }
.tree-header:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tree-toggle { width: 20px; font-weight: bold; color: var(--accent); text-align: center; user-select: none; }
.tree-group-name { flex: 1; font-weight: 500; }
.tree-count { color: var(--text-muted); font-size: 0.85em; }
.tree-items { display: none; padding: 0 16px 12px; flex-direction: column; gap: 4px; }
.tree-items--expanded { display: flex; }
.tree-item { .tree-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
padding: 8px 12px; padding: 6px 12px;
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
cursor: pointer; cursor: pointer;
transition: background var(--transition-fast); transition: background var(--transition-fast);
@@ -476,23 +466,24 @@ body {
.tree-item:hover { background: var(--bg-hover); } .tree-item:hover { background: var(--bg-hover); }
.tree-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .tree-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tree-item__img { width: 32px; height: 32px; object-fit: cover; border-radius: var(--border-radius-sm); flex-shrink: 0; } .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 { .tree-item__placeholder {
width: 32px; width: 28px;
height: 32px; height: 28px;
background: var(--bg-hover); background: var(--bg-hover);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 600; font-weight: 600;
font-size: 0.8em; font-size: 0.75em;
color: var(--accent); color: var(--accent);
flex-shrink: 0; flex-shrink: 0;
} }
.tree-item__name { flex: 1; font-size: 0.9em; } .tree-item__name { font-size: 0.85em; }
/* Graph View */ /* Graph View */
.content-area--graph { position: relative; padding: 0; overflow: hidden; } .content-area--graph { position: relative; padding: 0; overflow: hidden; }
@@ -730,8 +721,8 @@ body {
<script> <script>
/** /**
* DECK Frontend v4.3 * DECK Frontend v4.4
* Portable single-file with trivial extraction * Tree view from tree_* relational tables
*/ */
// ============================================================================= // =============================================================================
@@ -782,7 +773,7 @@ const State = {
base: "hst", view: "grid", lang: "es", base: "hst", view: "grid", lang: "es",
search: "", group: "all", library: "all", libraryMembers: new Set(), search: "", group: "all", library: "all", libraryMembers: new Set(),
selectionMode: false, selected: new Set(), selectedTag: null, selectionMode: false, selected: new Set(), selectedTag: null,
tags: [], hstTags: [], groups: [], libraries: [], edges: [], tags: [], hstTags: [], groups: [], libraries: [], edges: [], treeData: [],
graphFilters: { categories: new Set(Object.keys(CONFIG.CATEGORIES)), edgeTypes: new Set(Object.keys(CONFIG.EDGE_TYPES)) }, graphFilters: { categories: new Set(Object.keys(CONFIG.CATEGORIES)), edgeTypes: new Set(Object.keys(CONFIG.EDGE_TYPES)) },
graphSettings: { ...CONFIG.GRAPH_DEFAULTS } graphSettings: { ...CONFIG.GRAPH_DEFAULTS }
}, },
@@ -922,6 +913,12 @@ const API = {
return this.fetch(`/graph_${base}`, config.schema, { fallback: [] }); 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) { getChildren(mrf, base) {
const config = CONFIG.BASES[base]; const config = CONFIG.BASES[base];
if (!config) return Promise.resolve([]); if (!config) return Promise.resolve([]);
@@ -1125,53 +1122,74 @@ const TreeView = {
if (!tags.length) { area.innerHTML = '<div class="empty">Sin resultados</div>'; return; } if (!tags.length) { area.innerHTML = '<div class="empty">Sin resultados</div>'; return; }
const { lang, hstTags } = State.get(); const { lang, treeData } = State.get();
const grouped = {};
tags.forEach(tag => { // Create tag lookup map
const key = tag.set_hst || "_ungrouped"; const tagMap = new Map();
if (!grouped[key]) grouped[key] = []; tags.forEach(tag => tagMap.set(tag.mrf, tag));
grouped[key].push(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);
}
}); });
const groupNames = {}; // Find root nodes: tags that don't have a parent (or parent not in tagMap)
hstTags.forEach(h => { groupNames[h.mrf] = Utils.getName(h, lang); }); 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;
area.innerHTML = Object.entries(grouped).map(([key, items]) => {
const groupName = Utils.escapeHtml(groupNames[key] || key);
return ` return `
<div class="tree-group"> <div class="tree-node" data-depth="${depth}">
<div class="tree-header" tabindex="0" role="button" aria-expanded="false"> <div class="tree-item ${hasChildren ? 'tree-item--parent' : ''}" data-mrf="${safeMrf}" tabindex="0" role="button" style="padding-left: ${12 + depth * 20}px">
<span class="tree-toggle" aria-hidden="true">+</span> ${hasChildren ? '<span class="tree-toggle" aria-hidden="true">+</span>' : '<span class="tree-toggle tree-toggle--empty"></span>'}
<span class="tree-group-name">${groupName}</span> ${img ? `<img class="tree-item__img" src="${img}" alt="${name}">` : `<div class="tree-item__placeholder">${ref.slice(0, 1) || "?"}</div>`}
<span class="tree-count">${items.length}</span> <span class="tree-item__name">${name}</span>
</div> ${hasChildren ? `<span class="tree-count">${children.length}</span>` : ''}
<div class="tree-items" role="group">
${items.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);
return `
<div class="tree-item" data-mrf="${mrf}" tabindex="0" role="button">
${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>
</div>
`;
}).join("")}
</div> </div>
${hasChildren ? `<div class="tree-children" role="group">${children.map(c => renderNode(c, depth + 1)).join("")}</div>` : ''}
</div> </div>
`; `;
}).join(""); };
area.querySelectorAll(".tree-header").forEach(header => { if (!roots.length) {
header.onclick = header.onkeydown = (e) => { 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.key !== "Enter" && e.key !== " ") return;
if (e.type === "keydown") e.preventDefault(); if (e.type === "keydown") e.preventDefault();
const items = header.nextElementSibling;
const toggle = header.querySelector(".tree-toggle"); if (children) {
const isExpanded = items.classList.toggle("tree-items--expanded"); const isExpanded = children.classList.toggle("tree-children--expanded");
toggle.textContent = isExpanded ? "" : "+"; toggle.textContent = isExpanded ? "" : "+";
header.setAttribute("aria-expanded", isExpanded); }
}; };
}); });
} }
@@ -1494,10 +1512,10 @@ const App = {
async loadData(base) { async loadData(base) {
Utils.$("#content-area").innerHTML = '<div class="loading">Cargando...</div>'; Utils.$("#content-area").innerHTML = '<div class="loading">Cargando...</div>';
try { try {
const [tags, hstTags, groups, libraries, edges] = await Promise.all([ const [tags, hstTags, groups, libraries, edges, treeData] = await Promise.all([
API.getTags(base), API.getHstTags(), API.getGroups(), API.getLibraries(base), API.getEdges(base) API.getTags(base), API.getHstTags(), API.getGroups(), API.getLibraries(base), API.getEdges(base), API.getTree(base)
]); ]);
State.set({ tags, hstTags, groups, libraries, edges }); State.set({ tags, hstTags, groups, libraries, edges, treeData });
GroupsBar.render(); GroupsBar.render();
LibrariesPanel.render(); LibrariesPanel.render();
} catch (error) { } catch (error) {