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

View File

@@ -0,0 +1,491 @@
import { store } from '@/state/index.ts';
import { Router } from '@/router/index.ts';
import { fetchTags, fetchHstTags, fetchGroups, fetchLibraries, fetchLibraryMembers } from '@/api/index.ts';
import { GridView, TreeView, GraphView, DetailPanel } from '@/views/index.ts';
import { $, $$, delegateEvent, toast, createNameMap, resolveGroupName } from '@/utils/index.ts';
import { CATS, EDGE_COLORS } from '@/config/index.ts';
import type { BaseType, ViewType, CategoryKey, EdgeType } from '@/types/index.ts';
import './styles/main.css';
class App {
private router: Router;
private currentView: GridView | TreeView | GraphView | null = null;
private detailPanel: DetailPanel | null = null;
constructor() {
this.router = new Router(store, () => this.init());
}
async start(): Promise<void> {
this.router.parseHash();
await this.init();
this.bindEvents();
}
private async init(): Promise<void> {
const contentArea = $('#content-area');
const detailPanelEl = $('#detail-panel');
if (!contentArea || !detailPanelEl) return;
// Update UI
this.updateBaseButtons();
this.updateViewTabs();
// Show loading
contentArea.innerHTML = '<div class="loading">Cargando...</div>';
// Fetch data
const state = store.getState();
const [tags, hstTags, groups, libraries] = await Promise.all([
fetchTags(state.base),
fetchHstTags(), // Always load HST for group name resolution
fetchGroups(),
fetchLibraries(state.base) // Load libraries for current base
]);
store.setState({ tags, hstTags, groups, libraries });
// Render groups
this.renderGroups();
this.renderLibraries();
// Setup detail panel
if (!this.detailPanel) {
this.detailPanel = new DetailPanel(detailPanelEl, store);
}
// Render view
this.renderView();
}
private renderView(): void {
const contentArea = $('#content-area');
if (!contentArea) return;
const state = store.getState();
const showDetail = (mrf: string) => this.detailPanel?.showDetail(mrf);
// Unmount current view
this.currentView?.unmount();
// Clear and set class
contentArea.innerHTML = '';
contentArea.className = `content-area ${state.view}-view`;
// Mount new view
switch (state.view) {
case 'grid':
this.currentView = new GridView(contentArea, store, showDetail);
this.currentView.mount();
break;
case 'tree':
this.currentView = new TreeView(contentArea, store, showDetail);
this.currentView.mount();
break;
case 'graph':
this.currentView = new GraphView(contentArea, store, showDetail);
(this.currentView as GraphView).mount();
break;
}
}
private renderGroups(): void {
const container = $('#groups-bar');
if (!container) return;
const state = store.getState();
// Use hstTags for group name resolution (set_hst points to hst tags)
const nameMap = createNameMap(state.hstTags, state.lang);
// Count tags per group
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);
});
// Sort by count and take 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('')}
`;
delegateEvent<MouseEvent>(container, '.group-btn', 'click', (_, target) => {
const group = target.dataset.group || 'all';
store.setState({ group });
this.renderGroups();
this.renderView();
});
}
private renderLibraries(): void {
const container = $('#left-panel');
if (!container) return;
const state = store.getState();
// Show graph options when in graph view
if (state.view === 'graph') {
container.classList.add('graph-mode');
this.renderGraphOptions(container);
return;
}
container.classList.remove('graph-mode');
container.innerHTML = `
<div class="lib-icon ${state.library === 'all' ? 'active' : ''}" data-lib="all" title="Todos">
<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.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('')}
`;
delegateEvent<MouseEvent>(container, '.lib-icon', 'click', async (_, target) => {
const library = target.dataset.lib || 'all';
const currentBase = store.getState().base;
if (library === 'all') {
store.setState({ library: 'all', libraryMembers: new Set() });
} else {
const members = await fetchLibraryMembers(library, currentBase);
store.setState({ library, libraryMembers: new Set(members) });
}
this.renderLibraries();
this.renderView();
});
}
private renderGraphOptions(container: HTMLElement): void {
const state = store.getState();
const { graphFilters, graphSettings, tags, graphEdges } = state;
// Count nodes and edges
const nodeCount = tags.length;
const edgeCount = graphEdges.length;
container.innerHTML = `
<div class="graph-options">
<!-- Stats -->
<div class="graph-section">
<div class="graph-stat">
<span>Nodos</span>
<span class="graph-stat-value">${nodeCount}</span>
</div>
<div class="graph-stat">
<span>Edges</span>
<span class="graph-stat-value">${edgeCount}</span>
</div>
</div>
<!-- Categories -->
<div class="graph-section">
<div class="graph-section-title">Categorias</div>
${Object.entries(CATS).map(([key, config]) => `
<label class="graph-checkbox">
<input type="checkbox" data-cat="${key}" ${graphFilters.cats.has(key as CategoryKey) ? 'checked' : ''}>
<span class="color-dot" style="background: ${config.color}"></span>
${config.name}
</label>
`).join('')}
</div>
<!-- Edge Types -->
<div class="graph-section">
<div class="graph-section-title">Relaciones</div>
${Object.entries(EDGE_COLORS).map(([key, color]) => `
<label class="graph-checkbox">
<input type="checkbox" data-edge="${key}" ${graphFilters.edges.has(key as EdgeType) ? 'checked' : ''}>
<span class="color-dot" style="background: ${color}"></span>
${key}
</label>
`).join('')}
</div>
<!-- Visualization -->
<div class="graph-section">
<div class="graph-section-title">Visualizacion</div>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-img" ${graphSettings.showImg ? 'checked' : ''}>
Imagenes
</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>
`;
// Bind events
this.bindGraphOptionEvents(container);
}
private bindGraphOptionEvents(container: HTMLElement): void {
// Category checkboxes
container.querySelectorAll<HTMLInputElement>('[data-cat]').forEach(cb => {
cb.addEventListener('change', () => {
const cat = cb.dataset.cat as CategoryKey;
const state = store.getState();
const newCats = new Set(state.graphFilters.cats);
if (cb.checked) {
newCats.add(cat);
} else {
newCats.delete(cat);
}
store.setState({
graphFilters: { ...state.graphFilters, cats: newCats }
});
this.renderView();
});
});
// Edge checkboxes
container.querySelectorAll<HTMLInputElement>('[data-edge]').forEach(cb => {
cb.addEventListener('change', () => {
const edge = cb.dataset.edge as EdgeType;
const state = store.getState();
const newEdges = new Set(state.graphFilters.edges);
if (cb.checked) {
newEdges.add(edge);
} else {
newEdges.delete(edge);
}
store.setState({
graphFilters: { ...state.graphFilters, edges: newEdges }
});
this.renderView();
});
});
// Show images checkbox
const showImgCb = container.querySelector<HTMLInputElement>('#graph-show-img');
showImgCb?.addEventListener('change', () => {
const state = store.getState();
store.setState({
graphSettings: { ...state.graphSettings, showImg: showImgCb.checked }
});
this.renderView();
});
// Show labels checkbox
const showLblCb = container.querySelector<HTMLInputElement>('#graph-show-lbl');
showLblCb?.addEventListener('change', () => {
const state = store.getState();
store.setState({
graphSettings: { ...state.graphSettings, showLbl: showLblCb.checked }
});
this.renderView();
});
// 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 = store.getState();
store.setState({
graphSettings: { ...state.graphSettings, nodeSize: size }
});
this.renderView();
});
// 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 = store.getState();
store.setState({
graphSettings: { ...state.graphSettings, linkDist: dist }
});
this.renderView();
});
}
private updateBaseButtons(): void {
const state = store.getState();
$$('.base-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.base === state.base);
});
}
private updateViewTabs(): void {
const state = store.getState();
$$('.view-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.view === state.view);
});
}
private bindEvents(): void {
// Base buttons
delegateEvent<MouseEvent>(document.body, '.base-btn', 'click', async (_, target) => {
const base = target.dataset.base as BaseType;
if (!base) return;
store.setState({
base,
group: 'all',
library: 'all',
libraryMembers: new Set(),
search: '',
graphEdges: [],
treeEdges: [],
selected: new Set(),
selectionMode: false
});
this.router.updateHash();
await this.init();
});
// View tabs
delegateEvent<MouseEvent>(document.body, '.view-tab', 'click', (_, target) => {
const view = target.dataset.view as ViewType;
if (!view) return;
store.setState({ view });
this.router.updateHash();
this.detailPanel?.close();
this.updateViewTabs();
this.renderLibraries(); // Update left panel (graph options vs libraries)
this.renderView();
});
// Search
const searchInput = $('#search') as HTMLInputElement;
if (searchInput) {
let timeout: number;
searchInput.addEventListener('input', () => {
clearTimeout(timeout);
timeout = window.setTimeout(() => {
store.setState({ search: searchInput.value });
this.renderView();
}, 200);
});
}
// Language select
const langSelect = $('#lang-select') as HTMLSelectElement;
if (langSelect) {
langSelect.addEventListener('change', () => {
store.setState({ lang: langSelect.value as 'es' | 'en' | 'ch' });
this.renderView();
});
}
// Selection mode
const selBtn = $('#btn-sel');
if (selBtn) {
selBtn.addEventListener('click', () => {
const state = store.getState();
store.setState({
selectionMode: !state.selectionMode,
selected: state.selectionMode ? new Set() : state.selected
});
selBtn.classList.toggle('active', !state.selectionMode);
this.updateSelectionCount();
this.renderView();
});
}
// Get selected
const getBtn = $('#btn-get');
if (getBtn) {
getBtn.addEventListener('click', () => {
const state = store.getState();
if (state.selected.size === 0) {
toast('No hay seleccionados');
return;
}
navigator.clipboard.writeText([...state.selected].join('\n'))
.then(() => toast(`Copiados ${state.selected.size} mrfs`));
});
}
// API modal
const apiBtn = $('#btn-api');
const apiModal = $('#api-modal');
if (apiBtn && apiModal) {
apiBtn.addEventListener('click', () => apiModal.classList.add('open'));
apiModal.addEventListener('click', (e) => {
if (e.target === apiModal) apiModal.classList.remove('open');
});
const closeBtn = apiModal.querySelector('.modal-close');
closeBtn?.addEventListener('click', () => apiModal.classList.remove('open'));
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.detailPanel?.close();
$('#api-modal')?.classList.remove('open');
if (store.getState().selectionMode) {
store.setState({ selectionMode: false, selected: new Set() });
$('#btn-sel')?.classList.remove('active');
this.renderView();
}
}
if (e.key === '/' && (e.target as HTMLElement).tagName !== 'INPUT') {
e.preventDefault();
($('#search') as HTMLInputElement)?.focus();
}
});
// Suscribir al store para actualizar contador cuando cambia selected
store.subscribe((state, prevState) => {
if (state.selected !== prevState.selected) {
this.updateSelectionCount();
}
});
}
private updateSelectionCount(): void {
const counter = $('#sel-count');
if (counter) {
const count = store.getState().selected.size;
counter.textContent = count > 0 ? `(${count})` : '';
}
}
}
// Bootstrap
document.addEventListener('DOMContentLoaded', () => {
new App().start();
});