- 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>
485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|