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:
ARCHITECT
2026-01-16 18:26:59 +00:00
parent 17506aaee2
commit 9b244138b5
177 changed files with 15063 additions and 0 deletions

81
hst-frontend/js/api.js Normal file
View 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
View 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
View 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";

View 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
View 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
View 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();
}

View 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";
}
}

View 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);
}

View 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);
});
}

View 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);
};
});
}