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:
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user