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:
@@ -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) {
|
||||
Reference in New Issue
Block a user