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">
<!--
DECK FRONTEND v4.3 - Portable single-file
DECK FRONTEND v4.4 - Tree from tree_* tables
Extract: ./extract.sh deck.html [output_dir]
-->
<style>
/* =============================================================================
* DECK STYLES v4.3
* DECK STYLES v4.4
* ============================================================================= */
/* -----------------------------------------------------------------------------
@@ -444,31 +444,21 @@ body {
}
/* Tree View */
.content-area--tree { display: flex; flex-direction: column; gap: 8px; }
.tree-group { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--border-radius-lg); overflow: hidden; }
.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-header {
padding: 12px 16px;
display: flex;
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-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: 10px;
padding: 8px 12px;
gap: 8px;
padding: 6px 12px;
border-radius: var(--border-radius-md);
cursor: pointer;
transition: background var(--transition-fast);
@@ -476,23 +466,24 @@ body {
.tree-item:hover { background: var(--bg-hover); }
.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 {
width: 32px;
height: 32px;
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.8em;
font-size: 0.75em;
color: var(--accent);
flex-shrink: 0;
}
.tree-item__name { flex: 1; font-size: 0.9em; }
.tree-item__name { font-size: 0.85em; }
/* Graph View */
.content-area--graph { position: relative; padding: 0; overflow: hidden; }
@@ -730,8 +721,8 @@ body {
<script>
/**
* DECK Frontend v4.3
* Portable single-file with trivial extraction
* DECK Frontend v4.4
* Tree view from tree_* relational tables
*/
// =============================================================================
@@ -782,7 +773,7 @@ const State = {
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: [],
tags: [], hstTags: [], groups: [], libraries: [], edges: [], treeData: [],
graphFilters: { categories: new Set(Object.keys(CONFIG.CATEGORIES)), edgeTypes: new Set(Object.keys(CONFIG.EDGE_TYPES)) },
graphSettings: { ...CONFIG.GRAPH_DEFAULTS }
},
@@ -922,6 +913,12 @@ const API = {
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([]);
@@ -1125,53 +1122,74 @@ const TreeView = {
if (!tags.length) { area.innerHTML = '<div class="empty">Sin resultados</div>'; return; }
const { lang, hstTags } = State.get();
const grouped = {};
tags.forEach(tag => {
const key = tag.set_hst || "_ungrouped";
if (!grouped[key]) grouped[key] = [];
grouped[key].push(tag);
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);
}
});
const groupNames = {};
hstTags.forEach(h => { groupNames[h.mrf] = Utils.getName(h, lang); });
// 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 "";
area.innerHTML = Object.entries(grouped).map(([key, items]) => {
const groupName = Utils.escapeHtml(groupNames[key] || key);
return `
<div class="tree-group">
<div class="tree-header" tabindex="0" role="button" aria-expanded="false">
<span class="tree-toggle" aria-hidden="true">+</span>
<span class="tree-group-name">${groupName}</span>
<span class="tree-count">${items.length}</span>
</div>
<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);
const safeMrf = Utils.escapeHtml(mrf);
const children = childrenMap.get(mrf) || [];
const hasChildren = children.length > 0;
return `
<div class="tree-item" data-mrf="${mrf}" tabindex="0" role="button">
<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>
`;
}).join("")}
</div>
</div>
`;
}).join("");
};
area.querySelectorAll(".tree-header").forEach(header => {
header.onclick = header.onkeydown = (e) => {
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();
const items = header.nextElementSibling;
const toggle = header.querySelector(".tree-toggle");
const isExpanded = items.classList.toggle("tree-items--expanded");
if (children) {
const isExpanded = children.classList.toggle("tree-children--expanded");
toggle.textContent = isExpanded ? "" : "+";
header.setAttribute("aria-expanded", isExpanded);
}
};
});
}
@@ -1494,10 +1512,10 @@ const App = {
async loadData(base) {
Utils.$("#content-area").innerHTML = '<div class="loading">Cargando...</div>';
try {
const [tags, hstTags, groups, libraries, edges] = await Promise.all([
API.getTags(base), API.getHstTags(), API.getGroups(), API.getLibraries(base), API.getEdges(base)
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 });
State.set({ tags, hstTags, groups, libraries, edges, treeData });
GroupsBar.render();
LibrariesPanel.render();
} catch (error) {