- 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>
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
/**
|
|
* StandardModule - Módulo estándar para bases con vistas Grid/Tree/Graph
|
|
*
|
|
* Usado por: taxonomía (hst, flg, itm, loc, ply), maestros (mst, bck),
|
|
* registro (atc, mth)
|
|
*/
|
|
|
|
import { BaseModule } from '../registry.ts';
|
|
import { GridView, TreeView, GraphView } from '@/views/index.ts';
|
|
import { fetchTags, fetchHstTags, fetchGroups, fetchLibraries, fetchLibraryMembers } from '@/api/index.ts';
|
|
import { createNameMap, resolveGroupName, delegateEvent } from '@/utils/index.ts';
|
|
import type { ViewType } from '@/types/index.ts';
|
|
|
|
export class StandardModule extends BaseModule {
|
|
private currentView: GridView | TreeView | GraphView | null = null;
|
|
|
|
async mount(): Promise<void> {
|
|
// Show loading
|
|
this.ctx.container.innerHTML = '<div class="loading">Cargando...</div>';
|
|
|
|
// Load data
|
|
await this.loadData();
|
|
|
|
// Render sidebar and groups
|
|
this.renderSidebar();
|
|
this.renderGroupsBar();
|
|
|
|
// Render main view
|
|
this.render();
|
|
|
|
this.mounted = true;
|
|
}
|
|
|
|
unmount(): void {
|
|
this.currentView?.unmount();
|
|
this.currentView = null;
|
|
this.unsubscribe?.();
|
|
this.unsubscribe = null;
|
|
this.mounted = false;
|
|
}
|
|
|
|
async loadData(): Promise<void> {
|
|
const config = this.getConfig();
|
|
|
|
// Fetch tags para esta base
|
|
const tags = await fetchTags(config.id);
|
|
|
|
// Fetch HST tags para resolución de nombres de grupos (si tiene grupos)
|
|
const hstTags = config.api.hasGroups
|
|
? await fetchHstTags()
|
|
: [];
|
|
|
|
// Fetch grupos (solo si esta base los soporta)
|
|
const groups = config.api.hasGroups
|
|
? await fetchGroups()
|
|
: [];
|
|
|
|
// Fetch bibliotecas (solo si esta base las soporta)
|
|
const libraries = config.api.hasLibraries
|
|
? await fetchLibraries(config.id)
|
|
: [];
|
|
|
|
this.setState({
|
|
tags,
|
|
hstTags,
|
|
groups,
|
|
libraries,
|
|
library: 'all',
|
|
libraryMembers: new Set(),
|
|
group: 'all'
|
|
});
|
|
}
|
|
|
|
render(): void {
|
|
const state = this.getState();
|
|
const config = this.getConfig();
|
|
|
|
// Verificar que la vista está soportada
|
|
if (!this.isViewSupported(state.view)) {
|
|
// Cambiar a vista por defecto
|
|
const defaultView = config.defaultView as ViewType;
|
|
this.setState({ view: defaultView });
|
|
return;
|
|
}
|
|
|
|
// Unmount current view
|
|
this.currentView?.unmount();
|
|
|
|
// Clear container
|
|
this.ctx.container.innerHTML = '';
|
|
this.ctx.container.className = `content-area ${state.view}-view`;
|
|
|
|
// Mount new view
|
|
switch (state.view) {
|
|
case 'grid':
|
|
this.currentView = new GridView(this.ctx.container, this.ctx.store, this.ctx.showDetail);
|
|
this.currentView.mount();
|
|
break;
|
|
|
|
case 'tree':
|
|
if (config.views.tree) {
|
|
this.currentView = new TreeView(this.ctx.container, this.ctx.store, this.ctx.showDetail);
|
|
this.currentView.mount();
|
|
}
|
|
break;
|
|
|
|
case 'graph':
|
|
if (config.views.graph) {
|
|
this.currentView = new GraphView(this.ctx.container, this.ctx.store, this.ctx.showDetail);
|
|
(this.currentView as GraphView).mount();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
renderSidebar(): void {
|
|
const container = this.ctx.leftPanel;
|
|
const state = this.getState();
|
|
const config = this.getConfig();
|
|
|
|
// Si es vista de grafo, mostrar opciones de grafo
|
|
if (state.view === 'graph' && config.views.graph) {
|
|
container.classList.add('graph-mode');
|
|
this.renderGraphOptions(container);
|
|
return;
|
|
}
|
|
|
|
container.classList.remove('graph-mode');
|
|
|
|
// Si no tiene bibliotecas, vaciar sidebar
|
|
if (!config.api.hasLibraries) {
|
|
container.innerHTML = '<div class="sidebar-empty">Sin bibliotecas</div>';
|
|
return;
|
|
}
|
|
|
|
// Ordenar bibliotecas alfabéticamente
|
|
const sortedLibs = [...state.libraries].sort((a, b) => {
|
|
const nameA = a.name || a.name_es || a.alias || a.ref || '';
|
|
const nameB = b.name || b.name_es || b.alias || b.ref || '';
|
|
return nameA.localeCompare(nameB);
|
|
});
|
|
|
|
// Renderizar bibliotecas (simple - sin config por ahora)
|
|
container.innerHTML = `
|
|
<div class="lib-icon ${state.library === 'all' ? 'active' : ''}" data-lib="all" title="Todos">
|
|
<span>ALL</span>
|
|
</div>
|
|
${sortedLibs.map(lib => {
|
|
const icon = lib.img_thumb_url || lib.icon_url || '';
|
|
const name = lib.name || lib.name_es || lib.alias || 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('')}
|
|
`;
|
|
|
|
// Bind library clicks
|
|
delegateEvent<MouseEvent>(container, '.lib-icon', 'click', async (_, target) => {
|
|
const library = target.dataset.lib || 'all';
|
|
|
|
if (library === 'all') {
|
|
this.setState({ library: 'all', libraryMembers: new Set() });
|
|
} else {
|
|
const currentBase = this.getState().base;
|
|
const members = await fetchLibraryMembers(library, currentBase);
|
|
this.setState({ library, libraryMembers: new Set(members) });
|
|
}
|
|
|
|
this.renderSidebar();
|
|
this.render();
|
|
});
|
|
}
|
|
|
|
renderGroupsBar(): void {
|
|
const container = this.ctx.groupsBar;
|
|
const state = this.getState();
|
|
const config = this.getConfig();
|
|
|
|
// Si no tiene grupos, vaciar
|
|
if (!config.api.hasGroups) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
// Usar hstTags para resolución de nombres
|
|
const nameMap = createNameMap(state.hstTags, state.lang);
|
|
|
|
// Contar tags por grupo
|
|
const counts = new Map<string, number>();
|
|
state.tags.forEach(tag => {
|
|
const group = tag.set_hst || 'sin-grupo';
|
|
counts.set(group, (counts.get(group) || 0) + 1);
|
|
});
|
|
|
|
// Ordenar por count y tomar top 20
|
|
const sorted = Array.from(counts.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 20);
|
|
|
|
container.innerHTML = `
|
|
<button class="group-btn ${state.group === 'all' ? 'active' : ''}" data-group="all">
|
|
Todos (${state.tags.length})
|
|
</button>
|
|
${sorted.map(([groupMrf, count]) => {
|
|
const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap);
|
|
return `
|
|
<button class="group-btn ${state.group === groupMrf ? 'active' : ''}" data-group="${groupMrf}">
|
|
${groupName} (${count})
|
|
</button>
|
|
`;
|
|
}).join('')}
|
|
`;
|
|
|
|
// Bind group clicks
|
|
delegateEvent<MouseEvent>(container, '.group-btn', 'click', (_, target) => {
|
|
const group = target.dataset.group || 'all';
|
|
this.setState({ group });
|
|
this.renderGroupsBar();
|
|
this.render();
|
|
});
|
|
}
|
|
|
|
private renderGraphOptions(container: HTMLElement): void {
|
|
// TODO: Extraer a componente separado
|
|
const state = this.getState();
|
|
const { graphSettings, tags, graphEdges } = state;
|
|
|
|
container.innerHTML = `
|
|
<div class="graph-options">
|
|
<div class="graph-section">
|
|
<div class="graph-stat">
|
|
<span>Nodos</span>
|
|
<span class="graph-stat-value">${tags.length}</span>
|
|
</div>
|
|
<div class="graph-stat">
|
|
<span>Edges</span>
|
|
<span class="graph-stat-value">${graphEdges.length}</span>
|
|
</div>
|
|
</div>
|
|
<div class="graph-section">
|
|
<div class="graph-section-title">Visualización</div>
|
|
<label class="graph-checkbox">
|
|
<input type="checkbox" id="graph-show-img" ${graphSettings.showImg ? 'checked' : ''}>
|
|
Imágenes
|
|
</label>
|
|
<label class="graph-checkbox">
|
|
<input type="checkbox" id="graph-show-lbl" ${graphSettings.showLbl ? 'checked' : ''}>
|
|
Etiquetas
|
|
</label>
|
|
<div class="graph-slider">
|
|
<div class="graph-slider-label">
|
|
<span>Nodo</span>
|
|
<span class="graph-slider-value" id="node-size-val">${graphSettings.nodeSize}px</span>
|
|
</div>
|
|
<input type="range" id="graph-node-size" min="10" max="60" value="${graphSettings.nodeSize}">
|
|
</div>
|
|
<div class="graph-slider">
|
|
<div class="graph-slider-label">
|
|
<span>Distancia</span>
|
|
<span class="graph-slider-value" id="link-dist-val">${graphSettings.linkDist}px</span>
|
|
</div>
|
|
<input type="range" id="graph-link-dist" min="30" max="200" value="${graphSettings.linkDist}">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.bindGraphOptionEvents(container);
|
|
}
|
|
|
|
private bindGraphOptionEvents(container: HTMLElement): void {
|
|
// Show images checkbox
|
|
const showImgCb = container.querySelector<HTMLInputElement>('#graph-show-img');
|
|
showImgCb?.addEventListener('change', () => {
|
|
const state = this.getState();
|
|
this.setState({
|
|
graphSettings: { ...state.graphSettings, showImg: showImgCb.checked }
|
|
});
|
|
this.render();
|
|
});
|
|
|
|
// Show labels checkbox
|
|
const showLblCb = container.querySelector<HTMLInputElement>('#graph-show-lbl');
|
|
showLblCb?.addEventListener('change', () => {
|
|
const state = this.getState();
|
|
this.setState({
|
|
graphSettings: { ...state.graphSettings, showLbl: showLblCb.checked }
|
|
});
|
|
this.render();
|
|
});
|
|
|
|
// Node size slider
|
|
const nodeSizeSlider = container.querySelector<HTMLInputElement>('#graph-node-size');
|
|
const nodeSizeVal = container.querySelector('#node-size-val');
|
|
nodeSizeSlider?.addEventListener('input', () => {
|
|
const size = parseInt(nodeSizeSlider.value, 10);
|
|
if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`;
|
|
const state = this.getState();
|
|
this.setState({
|
|
graphSettings: { ...state.graphSettings, nodeSize: size }
|
|
});
|
|
this.render();
|
|
});
|
|
|
|
// Link distance slider
|
|
const linkDistSlider = container.querySelector<HTMLInputElement>('#graph-link-dist');
|
|
const linkDistVal = container.querySelector('#link-dist-val');
|
|
linkDistSlider?.addEventListener('input', () => {
|
|
const dist = parseInt(linkDistSlider.value, 10);
|
|
if (linkDistVal) linkDistVal.textContent = `${dist}px`;
|
|
const state = this.getState();
|
|
this.setState({
|
|
graphSettings: { ...state.graphSettings, linkDist: dist }
|
|
});
|
|
this.render();
|
|
});
|
|
}
|
|
}
|
|
|
|
export default StandardModule;
|