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">
|
<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 "";
|
||||||
|
|
||||||
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 name = Utils.escapeHtml(Utils.getName(tag, lang));
|
||||||
const img = Utils.resolveImage(tag.img_thumb_url);
|
const img = Utils.resolveImage(tag.img_thumb_url);
|
||||||
const ref = Utils.escapeHtml(tag.ref || "");
|
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 `
|
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>`}
|
${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>
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join("")}
|
};
|
||||||
</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) {
|
||||||
Reference in New Issue
Block a user