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,43 @@
import { API_BASE } from '@/config/index.ts';
interface FetchOptions {
method?: 'GET' | 'POST';
body?: Record<string, unknown>;
schema?: string; // PostgREST Accept-Profile header
}
export async function apiClient<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<T> {
const { method = 'GET', body, schema } = options;
const headers: Record<string, string> = {};
if (body) headers['Content-Type'] = 'application/json';
if (schema) headers['Accept-Profile'] = schema;
const config: RequestInit = {
method,
headers: Object.keys(headers).length > 0 ? headers : undefined,
body: body ? JSON.stringify(body) : undefined,
};
const response = await fetch(`${API_BASE}${endpoint}`, config);
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
export async function apiClientSafe<T>(
endpoint: string,
options: FetchOptions = {},
fallback: T
): Promise<T> {
try {
return await apiClient<T>(endpoint, options);
} catch {
console.error(`API call failed: ${endpoint}`);
return fallback;
}
}

View File

@@ -0,0 +1,8 @@
import { apiClientSafe } from './client.ts';
import type { GraphEdge, TreeEdge } from '@/types/index.ts';
export const fetchGraphEdges = (): Promise<GraphEdge[]> =>
apiClientSafe<GraphEdge[]>('/graph_hst', {}, []);
export const fetchTreeEdges = (): Promise<TreeEdge[]> =>
apiClientSafe<TreeEdge[]>('/tree_hst', {}, []);

View File

@@ -0,0 +1,5 @@
import { apiClientSafe } from './client.ts';
import type { Group } from '@/types/index.ts';
export const fetchGroups = (): Promise<Group[]> =>
apiClientSafe<Group[]>('/api_groups', {}, []);

View File

@@ -0,0 +1,5 @@
export { apiClient, apiClientSafe } from './client.ts';
export { fetchTags, fetchHstTags, fetchChildren, fetchRelated } from './tags.ts';
export { fetchGroups } from './groups.ts';
export { fetchLibraries, fetchLibraryMembers } from './libraries.ts';
export { fetchGraphEdges, fetchTreeEdges } from './graph.ts';

View File

@@ -0,0 +1,26 @@
import { apiClientSafe } from './client.ts';
import type { Library, BaseType } from '@/types/index.ts';
// Base types that have library tables (public schema taxonomy tables)
const LIBRARY_BASES = new Set(['hst', 'flg', 'itm', 'loc', 'ply']);
export const fetchLibraries = (base: BaseType): Promise<Library[]> => {
// Only public schema taxonomy tables have libraries
if (!LIBRARY_BASES.has(base)) {
return Promise.resolve([]);
}
// Use base-specific view: api_library_list_hst, api_library_list_flg, etc.
return apiClientSafe<Library[]>(`/api_library_list_${base}`, {}, []);
};
export const fetchLibraryMembers = async (mrf: string, base: BaseType): Promise<string[]> => {
if (!LIBRARY_BASES.has(base)) {
return [];
}
const data = await apiClientSafe<Array<{ mrf_tag: string }>>(
`/library_${base}?mrf_library=eq.${mrf}`,
{},
[]
);
return data.map(d => d.mrf_tag);
};

View File

@@ -0,0 +1,65 @@
import { apiClientSafe } from './client.ts';
import type { Tag, ChildTag, RelatedTag, BaseType } from '@/types/index.ts';
// Schema mapping by base type
// - public (default): hst, flg, itm, loc, ply
// - secretaria_clara: atc, mst, bck
// - production_alfred: mth
// - mail_manager: mail (table: clara_registros)
// - context_manager: chat (table: messages)
interface SchemaTableConfig {
schema: string | null;
table: string;
}
const getSchemaAndTable = (base: BaseType): SchemaTableConfig => {
switch (base) {
// secretaria_clara schema
case 'atc':
case 'mst':
case 'bck':
return { schema: 'secretaria_clara', table: base };
// production_alfred schema
case 'mth':
return { schema: 'production_alfred', table: base };
// mail_manager schema
case 'mail':
return { schema: 'mail_manager', table: 'clara_registros' };
// context_manager schema
case 'chat':
return { schema: 'context_manager', table: 'messages' };
// public schema (default) - hst, flg, itm, loc, ply
default:
return { schema: null, table: base };
}
};
export const fetchTags = (base: BaseType): Promise<Tag[]> => {
const { schema, table } = getSchemaAndTable(base);
return apiClientSafe<Tag[]>(
`/${table}?order=ref.asc`,
schema ? { schema } : {},
[]
);
};
// Fetch HST tags for group name resolution (set_hst points to hst tags)
export const fetchHstTags = (): Promise<Tag[]> =>
apiClientSafe<Tag[]>('/hst?select=mrf,ref,alias,name_es,name_en,name_ch', {}, []);
export const fetchChildren = (mrf: string): Promise<ChildTag[]> =>
apiClientSafe<ChildTag[]>('/rpc/api_children', {
method: 'POST',
body: { parent_mrf: mrf }
}, []);
export const fetchRelated = (mrf: string): Promise<RelatedTag[]> =>
apiClientSafe<RelatedTag[]>('/rpc/api_related', {
method: 'POST',
body: { tag_mrf: mrf }
}, []);

View File

@@ -0,0 +1,47 @@
import { Component } from '../Component.ts';
import type { Tag, LangType } from '@/types/index.ts';
import { getName, getImg } from '@/utils/index.ts';
export interface CardProps {
tag: Tag;
lang: LangType;
selected: boolean;
selectionMode: boolean;
onClick: (mrf: string) => void;
onSelect: (mrf: string) => void;
}
export class Card extends Component<CardProps> {
protected template(): string {
const { tag, lang, selected, selectionMode } = this.props;
const img = getImg(tag);
const name = getName(tag, lang);
return `
<div class="card ${selected ? 'selected' : ''}" data-mrf="${tag.mrf}">
${selectionMode ? `
<input type="checkbox" class="card-checkbox" ${selected ? 'checked' : ''}>
` : ''}
${img
? `<img class="card-img" src="${img}" alt="${tag.ref}" loading="lazy">`
: `<div class="card-placeholder">${tag.ref?.slice(0, 2) || 'T'}</div>`
}
<div class="card-name">${name}</div>
</div>
`;
}
protected bindEvents(): void {
const { onClick, onSelect, selectionMode } = this.props;
const mrf = this.props.tag.mrf;
this.element.addEventListener('click', (e) => {
if (selectionMode) {
e.preventDefault();
onSelect(mrf);
} else {
onClick(mrf);
}
});
}
}

View File

@@ -0,0 +1,42 @@
export abstract class Component<P extends object = object> {
protected element: HTMLElement;
protected props: P;
constructor(props: P) {
this.props = props;
this.element = this.createElement();
this.bindEvents();
}
protected abstract template(): string;
protected createElement(): HTMLElement {
const wrapper = document.createElement('div');
wrapper.innerHTML = this.template().trim();
return wrapper.firstElementChild as HTMLElement;
}
protected bindEvents(): void {
// Override in subclasses
}
public mount(container: HTMLElement): void {
container.appendChild(this.element);
}
public unmount(): void {
this.element.remove();
}
public update(props: Partial<P>): void {
this.props = { ...this.props, ...props };
const newElement = this.createElement();
this.element.replaceWith(newElement);
this.element = newElement;
this.bindEvents();
}
public getElement(): HTMLElement {
return this.element;
}
}

View File

@@ -0,0 +1,46 @@
import { Component } from '../Component.ts';
export interface ModalProps {
title: string;
content: string;
isOpen: boolean;
onClose: () => void;
}
export class Modal extends Component<ModalProps> {
protected template(): string {
const { title, content, isOpen } = this.props;
return `
<div class="modal ${isOpen ? 'open' : ''}">
<div class="modal-content">
<div class="modal-header">
<h3>${title}</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
${content}
</div>
</div>
</div>
`;
}
protected bindEvents(): void {
const closeBtn = this.element.querySelector('.modal-close');
closeBtn?.addEventListener('click', this.props.onClose);
this.element.addEventListener('click', (e) => {
if (e.target === this.element) {
this.props.onClose();
}
});
}
public open(): void {
this.element.classList.add('open');
}
public close(): void {
this.element.classList.remove('open');
}
}

View File

@@ -0,0 +1,25 @@
import { Component } from '../Component.ts';
export interface TagChipProps {
mrf: string;
label: string;
title?: string;
onClick: (mrf: string) => void;
}
export class TagChip extends Component<TagChipProps> {
protected template(): string {
const { mrf, label, title } = this.props;
return `
<span class="tag-chip" data-mrf="${mrf}" title="${title || ''}">
${label}
</span>
`;
}
protected bindEvents(): void {
this.element.addEventListener('click', () => {
this.props.onClick(this.props.mrf);
});
}
}

View File

@@ -0,0 +1,4 @@
export { Component } from './Component.ts';
export { Card, type CardProps } from './Card/Card.ts';
export { TagChip, type TagChipProps } from './TagChip/TagChip.ts';
export { Modal, type ModalProps } from './Modal/Modal.ts';

View File

@@ -0,0 +1 @@
export const API_BASE = '/api';

View File

@@ -0,0 +1,15 @@
import type { CategoryKey } from '@/types/index.ts';
export interface CategoryConfig {
name: string;
color: string;
}
export const CATS: Record<CategoryKey, CategoryConfig> = {
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' }
};

View File

@@ -0,0 +1,14 @@
import type { EdgeType } from '@/types/index.ts';
export const EDGE_COLORS: Record<EdgeType, string> = {
relation: '#8BC34A',
specialization: '#9C27B0',
mirror: '#607D8B',
dependency: '#2196F3',
sequence: '#4CAF50',
composition: '#FF9800',
hierarchy: '#E91E63',
library: '#00BCD4',
contextual: '#FFC107',
association: '#795548'
};

View File

@@ -0,0 +1,3 @@
export { CATS, type CategoryConfig } from './categories.ts';
export { EDGE_COLORS } from './edges.ts';
export { API_BASE } from './api.ts';

View File

@@ -0,0 +1,484 @@
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();
});

View File

@@ -0,0 +1,368 @@
/**
* Module Configurations - Registro central de todos los módulos
*/
import type { BaseConfig, ModuleCategory } from '../registry.ts';
import type { BaseType, ViewType } from '@/types/index.ts';
// Configuración de todos los módulos
export const MODULE_CONFIGS: Record<BaseType, BaseConfig> = {
// ═══════════════════════════════════════════════════════════════
// TAXONOMÍA (public schema)
// ═══════════════════════════════════════════════════════════════
hst: {
id: 'hst',
name: 'Hashtags Semánticos',
shortName: 'HST',
category: 'taxonomy',
renderType: 'standard',
views: { grid: true, tree: true, graph: true },
defaultView: 'grid',
api: {
schema: null,
table: 'hst',
hasLibraries: true,
hasGroups: true,
hasGraph: true,
hasTree: true
},
enabled: true
},
flg: {
id: 'flg',
name: 'Flags',
shortName: 'FLG',
category: 'taxonomy',
renderType: 'standard',
views: { grid: true, tree: true, graph: false },
defaultView: 'grid',
api: {
schema: null,
table: 'flg',
hasLibraries: true,
hasGroups: true,
hasGraph: false,
hasTree: false
},
enabled: true
},
itm: {
id: 'itm',
name: 'Items',
shortName: 'ITM',
category: 'taxonomy',
renderType: 'standard',
views: { grid: true, tree: true, graph: false },
defaultView: 'grid',
api: {
schema: null,
table: 'itm',
hasLibraries: true,
hasGroups: true,
hasGraph: false,
hasTree: false
},
enabled: true
},
loc: {
id: 'loc',
name: 'Locations',
shortName: 'LOC',
category: 'taxonomy',
renderType: 'standard',
views: { grid: true, tree: true, graph: false },
defaultView: 'grid',
api: {
schema: null,
table: 'loc',
hasLibraries: true,
hasGroups: true,
hasGraph: false,
hasTree: false
},
enabled: true
},
ply: {
id: 'ply',
name: 'Players',
shortName: 'PLY',
category: 'taxonomy',
renderType: 'standard',
views: { grid: true, tree: true, graph: false },
defaultView: 'grid',
api: {
schema: null,
table: 'ply',
hasLibraries: true,
hasGroups: true,
hasGraph: false,
hasTree: false
},
enabled: true
},
// ═══════════════════════════════════════════════════════════════
// MAESTROS (secretaria_clara schema)
// ═══════════════════════════════════════════════════════════════
mst: {
id: 'mst',
name: 'Masters',
shortName: 'MST',
category: 'masters',
renderType: 'standard',
views: { grid: true, tree: false, graph: false },
defaultView: 'grid',
api: {
schema: 'secretaria_clara',
table: 'mst',
hasLibraries: false,
hasGroups: false,
hasGraph: false,
hasTree: false
},
enabled: true
},
bck: {
id: 'bck',
name: 'Backups',
shortName: 'BCK',
category: 'masters',
renderType: 'standard',
views: { grid: true, tree: false, graph: false },
defaultView: 'grid',
api: {
schema: 'secretaria_clara',
table: 'bck',
hasLibraries: false,
hasGroups: false,
hasGraph: false,
hasTree: false
},
enabled: true
},
// ═══════════════════════════════════════════════════════════════
// REGISTRO (secretaria_clara / production_alfred)
// ═══════════════════════════════════════════════════════════════
atc: {
id: 'atc',
name: 'Attachments',
shortName: 'ATC',
category: 'registry',
renderType: 'standard',
views: { grid: true, tree: false, graph: false },
defaultView: 'grid',
api: {
schema: 'secretaria_clara',
table: 'atc',
hasLibraries: false,
hasGroups: false,
hasGraph: false,
hasTree: false
},
enabled: true
},
mth: {
id: 'mth',
name: 'Methods',
shortName: 'MTH',
category: 'registry',
renderType: 'standard',
views: { grid: true, tree: false, graph: false },
defaultView: 'grid',
api: {
schema: 'production_alfred',
table: 'mth',
hasLibraries: false,
hasGroups: false,
hasGraph: false,
hasTree: false
},
enabled: true
},
// ═══════════════════════════════════════════════════════════════
// COMUNICACIÓN (mail_manager / context_manager)
// Interfaz de chat con IA
// ═══════════════════════════════════════════════════════════════
mail: {
id: 'mail',
name: 'Mail Assistant',
shortName: 'MAIL',
category: 'communication',
renderType: 'chat',
views: { custom: 'ChatView' },
defaultView: 'custom',
api: {
schema: 'mail_manager',
table: 'clara_registros',
hasLibraries: false,
hasGroups: false,
hasGraph: false,
hasTree: false
},
customModule: () => import('../custom/MailModule.ts'),
enabled: false // Próximamente
},
chat: {
id: 'chat',
name: 'Context Manager',
shortName: 'CHAT',
category: 'communication',
renderType: 'chat',
views: { custom: 'ChatView' },
defaultView: 'custom',
api: {
schema: 'context_manager',
table: 'messages',
hasLibraries: false,
hasGroups: false,
hasGraph: false,
hasTree: false
},
customModule: () => import('../custom/ContextModule.ts'),
enabled: false // Próximamente
},
// ═══════════════════════════════════════════════════════════════
// SERVICIOS (interfaces custom)
// ═══════════════════════════════════════════════════════════════
key: {
id: 'key',
name: 'Keys',
shortName: 'KEY',
category: 'services',
renderType: 'custom',
views: { custom: 'KeyView' },
defaultView: 'custom',
api: {
schema: null,
table: 'key',
hasLibraries: false,
hasGroups: false,
hasGraph: false,
hasTree: false
},
customModule: () => import('../custom/KeyModule.ts'),
enabled: false // Próximamente
},
mindlink: {
id: 'mindlink',
name: 'MindLink',
shortName: 'MIND',
category: 'services',
renderType: 'custom',
views: { custom: 'MindlinkView' },
defaultView: 'custom',
api: {
schema: null,
table: 'mindlink',
hasLibraries: false,
hasGroups: false,
hasGraph: false,
hasTree: false
},
customModule: () => import('../custom/MindlinkModule.ts'),
enabled: false // Próximamente
}
};
// ═══════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════
/**
* Obtener configuración de un módulo
*/
export const getModuleConfig = (base: BaseType): BaseConfig => {
const config = MODULE_CONFIGS[base];
if (!config) {
throw new Error(`Module config not found for base: ${base}`);
}
return config;
};
/**
* Agrupar módulos por categoría para UI
*/
export const getModulesByCategory = (): Record<ModuleCategory, BaseConfig[]> => {
const result: Record<ModuleCategory, BaseConfig[]> = {
taxonomy: [],
masters: [],
registry: [],
communication: [],
services: []
};
Object.values(MODULE_CONFIGS).forEach(config => {
result[config.category].push(config);
});
return result;
};
/**
* Obtener solo módulos habilitados
*/
export const getEnabledModules = (): BaseConfig[] => {
return Object.values(MODULE_CONFIGS).filter(c => c.enabled !== false);
};
/**
* Verificar si un módulo está habilitado
*/
export const isModuleEnabled = (base: BaseType): boolean => {
return MODULE_CONFIGS[base]?.enabled !== false;
};
/**
* Obtener schema y tabla para API (compatibilidad con código existente)
*/
export const getSchemaAndTable = (base: BaseType): { schema: string | null; table: string } => {
const config = MODULE_CONFIGS[base];
if (!config) {
return { schema: null, table: base };
}
return {
schema: config.api.schema,
table: config.api.table
};
};
/**
* Verificar si una base soporta bibliotecas
*/
export const supportsLibraries = (base: BaseType): boolean => {
return MODULE_CONFIGS[base]?.api.hasLibraries ?? false;
};
/**
* Verificar si una base soporta grupos
*/
export const supportsGroups = (base: BaseType): boolean => {
return MODULE_CONFIGS[base]?.api.hasGroups ?? false;
};
/**
* Verificar si una vista está soportada por una base
*/
export const supportsView = (base: BaseType, view: ViewType): boolean => {
const config = MODULE_CONFIGS[base];
if (!config) return false;
return !!config.views[view];
};
/**
* Obtener vista por defecto de una base
*/
export const getDefaultView = (base: BaseType): ViewType | 'custom' => {
return MODULE_CONFIGS[base]?.defaultView ?? 'grid';
};

View File

@@ -0,0 +1,31 @@
/**
* ContextModule - Módulo de chat con IA genérico
*
* TODO: Implementar interfaz de chat estilo ChatGPT/Claude
* para interactuar con diferentes modelos de IA controlando el contexto.
*/
import { BaseModule } from '../registry.ts';
export class ContextModule extends BaseModule {
async mount(): Promise<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">💬</div>
<div class="module-disabled-title">Context Manager</div>
<div class="module-disabled-text">Chat con IA - Próximamente</div>
</div>
`;
}
}
export default ContextModule;

View File

@@ -0,0 +1,30 @@
/**
* KeyModule - Módulo de gestión de claves
*
* TODO: Implementar interfaz para gestionar claves y credenciales.
*/
import { BaseModule } from '../registry.ts';
export class KeyModule extends BaseModule {
async mount(): Promise<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">🔑</div>
<div class="module-disabled-title">Keys</div>
<div class="module-disabled-text">Gestión de claves - Próximamente</div>
</div>
`;
}
}
export default KeyModule;

View File

@@ -0,0 +1,31 @@
/**
* MailModule - Módulo de chat con IA para mail
*
* TODO: Implementar interfaz de chat estilo ChatGPT/Claude
* donde el contexto es el correo electrónico.
*/
import { BaseModule } from '../registry.ts';
export class MailModule extends BaseModule {
async mount(): Promise<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">📧</div>
<div class="module-disabled-title">Mail Assistant</div>
<div class="module-disabled-text">Interfaz de chat con IA - Próximamente</div>
</div>
`;
}
}
export default MailModule;

View File

@@ -0,0 +1,31 @@
/**
* MindlinkModule - Módulo de gestión de hipervínculos
*
* TODO: Implementar interfaz de árboles visuales con imágenes
* y vínculos a archivos/recursos.
*/
import { BaseModule } from '../registry.ts';
export class MindlinkModule extends BaseModule {
async mount(): Promise<void> {
this.render();
this.mounted = true;
}
unmount(): void {
this.mounted = false;
}
render(): void {
this.ctx.container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">🔗</div>
<div class="module-disabled-title">MindLink</div>
<div class="module-disabled-text">Gestión de hipervínculos - Próximamente</div>
</div>
`;
}
}
export default MindlinkModule;

View File

@@ -0,0 +1,4 @@
export { MailModule } from './MailModule.ts';
export { ContextModule } from './ContextModule.ts';
export { KeyModule } from './KeyModule.ts';
export { MindlinkModule } from './MindlinkModule.ts';

View File

@@ -0,0 +1,35 @@
/**
* Modules - Sistema modular para DECK Frontend
*/
// Registry (tipos e interfaces)
export {
type ModuleRenderType,
type ModuleCategory,
type ModuleViews,
type ModuleApiConfig,
type BaseConfig,
type ModuleState,
type ModuleContext,
BaseModule
} from './registry.ts';
// Configs (registro de módulos)
export {
MODULE_CONFIGS,
getModuleConfig,
getModulesByCategory,
getEnabledModules,
isModuleEnabled,
getSchemaAndTable,
supportsLibraries,
supportsGroups,
supportsView,
getDefaultView
} from './configs/index.ts';
// Loader
export { ModuleLoader, type LoaderTargets } from './loader.ts';
// Standard module
export { StandardModule } from './standard/index.ts';

View File

@@ -0,0 +1,174 @@
/**
* ModuleLoader - Carga dinámica de módulos
*
* Responsabilidades:
* - Cargar el módulo correcto según la base
* - Manejar cache de módulos
* - Gestionar lifecycle (mount/unmount)
*/
import { BaseModule, type ModuleContext } from './registry.ts';
import { getModuleConfig, isModuleEnabled } from './configs/index.ts';
import { StandardModule } from './standard/index.ts';
import type { Store } from '@/state/store.ts';
import type { AppState, BaseType } from '@/types/index.ts';
export interface LoaderTargets {
container: HTMLElement;
leftPanel: HTMLElement;
groupsBar: HTMLElement;
showDetail: (mrf: string) => void;
}
export class ModuleLoader {
private store: Store<AppState>;
private currentModule: BaseModule | null = null;
private currentBase: BaseType | null = null;
constructor(store: Store<AppState>) {
this.store = store;
}
/**
* Cargar módulo para una base
*/
async load(base: BaseType, targets: LoaderTargets): Promise<void> {
const config = getModuleConfig(base);
// Verificar si el módulo está habilitado
if (!isModuleEnabled(base)) {
this.showDisabledMessage(targets.container, config.name);
targets.leftPanel.innerHTML = '';
targets.groupsBar.innerHTML = '';
return;
}
// Si ya está cargado el mismo módulo, solo re-renderizar
if (this.currentBase === base && this.currentModule) {
this.currentModule.render();
return;
}
// Unmount módulo actual
this.currentModule?.unmount();
this.currentModule = null;
this.currentBase = null;
// Show loading
targets.container.innerHTML = '<div class="loading">Cargando...</div>';
// Crear contexto
const ctx: ModuleContext = {
container: targets.container,
leftPanel: targets.leftPanel,
groupsBar: targets.groupsBar,
store: this.store,
config,
showDetail: targets.showDetail
};
// Crear módulo según tipo
let module: BaseModule;
switch (config.renderType) {
case 'standard':
module = new StandardModule(ctx);
break;
case 'chat':
case 'custom':
// Carga dinámica de módulos custom
if (config.customModule) {
try {
const { default: CustomModule } = await config.customModule();
module = new CustomModule(ctx);
} catch (error) {
console.error(`Failed to load custom module for ${base}:`, error);
this.showErrorMessage(targets.container, `Error cargando módulo ${config.name}`);
return;
}
} else {
this.showDisabledMessage(targets.container, config.name);
return;
}
break;
default:
console.error(`Unknown render type: ${config.renderType}`);
this.showErrorMessage(targets.container, 'Tipo de módulo desconocido');
return;
}
// Mount módulo
try {
await module.mount();
this.currentModule = module;
this.currentBase = base;
} catch (error) {
console.error(`Failed to mount module for ${base}:`, error);
this.showErrorMessage(targets.container, `Error inicializando ${config.name}`);
}
}
/**
* Re-renderizar módulo actual (ej: cuando cambia la vista)
*/
rerender(): void {
if (this.currentModule) {
this.currentModule.render();
}
}
/**
* Re-renderizar sidebar del módulo actual
*/
rerenderSidebar(): void {
if (this.currentModule) {
this.currentModule.renderSidebar();
}
}
/**
* Obtener módulo actual
*/
getCurrentModule(): BaseModule | null {
return this.currentModule;
}
/**
* Obtener base actual
*/
getCurrentBase(): BaseType | null {
return this.currentBase;
}
/**
* Unmount módulo actual
*/
unmount(): void {
this.currentModule?.unmount();
this.currentModule = null;
this.currentBase = null;
}
private showDisabledMessage(container: HTMLElement, moduleName: string): void {
container.innerHTML = `
<div class="module-disabled">
<div class="module-disabled-icon">🚧</div>
<div class="module-disabled-title">${moduleName}</div>
<div class="module-disabled-text">Próximamente</div>
</div>
`;
}
private showErrorMessage(container: HTMLElement, message: string): void {
container.innerHTML = `
<div class="module-error">
<div class="module-error-icon">⚠️</div>
<div class="module-error-text">${message}</div>
</div>
`;
}
}
export default ModuleLoader;

View File

@@ -0,0 +1,170 @@
/**
* Module Registry - Tipos e interfaces para el sistema modular
*/
import type { Store } from '@/state/store.ts';
import type { AppState, ViewType, BaseType } from '@/types/index.ts';
// Tipos de renderizado de módulos
export type ModuleRenderType =
| 'standard' // Grid/Tree/Graph normal (taxonomía, atc)
| 'chat' // Interfaz de chat con IA (mail, context)
| 'custom'; // Interfaz completamente custom (key, mindlink)
// Categorías de módulos para agrupar en UI
export type ModuleCategory =
| 'taxonomy' // hst, flg, itm, loc, ply
| 'masters' // mst, bck
| 'registry' // atc, mth
| 'communication' // mail, chat
| 'services'; // key, mindlink
// Configuración de vistas soportadas por módulo
export interface ModuleViews {
grid?: boolean;
tree?: boolean;
graph?: boolean;
custom?: string; // Nombre del componente custom a usar
}
// Configuración de API por módulo
export interface ModuleApiConfig {
schema: string | null; // PostgREST schema (null = public)
table: string; // Tabla principal
hasLibraries?: boolean; // ¿Soporta bibliotecas?
hasGroups?: boolean; // ¿Soporta grupos (set_hst)?
hasGraph?: boolean; // ¿Tiene datos de grafo?
hasTree?: boolean; // ¿Tiene datos de árbol?
}
// Configuración completa de un módulo
export interface BaseConfig {
id: BaseType;
name: string; // Nombre completo
shortName: string; // Para botón (3-4 chars)
category: ModuleCategory;
renderType: ModuleRenderType;
// Vistas soportadas
views: ModuleViews;
defaultView: ViewType | 'custom';
// API
api: ModuleApiConfig;
// Para módulos custom (lazy loading)
customModule?: () => Promise<{ default: new (ctx: ModuleContext) => BaseModule }>;
// Estado inicial específico del módulo
initialState?: Partial<ModuleState>;
// Módulo habilitado (false = mostrar "Próximamente")
enabled?: boolean;
}
// Estado específico de un módulo
export interface ModuleState {
loading: boolean;
error: string | null;
data: unknown;
}
// Contexto pasado a cada módulo
export interface ModuleContext {
container: HTMLElement;
leftPanel: HTMLElement;
groupsBar: HTMLElement;
store: Store<AppState>;
config: BaseConfig;
showDetail: (mrf: string) => void;
}
// Clase base abstracta para módulos
export abstract class BaseModule {
protected ctx: ModuleContext;
protected mounted = false;
protected unsubscribe: (() => void) | null = null;
constructor(ctx: ModuleContext) {
this.ctx = ctx;
}
// Lifecycle
abstract mount(): Promise<void>;
abstract unmount(): void;
abstract render(): void;
// Override para carga de datos específica
async loadData(): Promise<void> {
// Default: no hace nada, subclases implementan
}
// Override para contenido del sidebar (libraries/options)
renderSidebar(): void {
// Default: vacío
this.ctx.leftPanel.innerHTML = '';
}
// Override para barra de grupos
renderGroupsBar(): void {
// Default: vacío
this.ctx.groupsBar.innerHTML = '';
}
// Helpers
protected getState(): Readonly<AppState> {
return this.ctx.store.getState();
}
protected setState(partial: Partial<AppState>): void {
this.ctx.store.setState(partial);
}
protected getConfig(): BaseConfig {
return this.ctx.config;
}
protected subscribe(listener: (state: AppState) => void): void {
this.unsubscribe = this.ctx.store.subscribe(listener);
}
// Verificar si una vista está soportada
protected isViewSupported(view: ViewType): boolean {
return !!this.ctx.config.views[view];
}
}
// Helper para obtener config de módulo
export const getModuleConfig = (configs: Record<BaseType, BaseConfig>, base: BaseType): BaseConfig => {
const config = configs[base];
if (!config) {
throw new Error(`Module config not found for base: ${base}`);
}
return config;
};
// Helper para agrupar módulos por categoría
export const getModulesByCategory = (
configs: Record<BaseType, BaseConfig>
): Record<ModuleCategory, BaseConfig[]> => {
const result: Record<ModuleCategory, BaseConfig[]> = {
taxonomy: [],
masters: [],
registry: [],
communication: [],
services: []
};
Object.values(configs).forEach(config => {
result[config.category].push(config);
});
return result;
};
// Helper para obtener módulos habilitados
export const getEnabledModules = (
configs: Record<BaseType, BaseConfig>
): BaseConfig[] => {
return Object.values(configs).filter(c => c.enabled !== false);
};

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { StandardModule, default } from './StandardModule.ts';

View File

@@ -0,0 +1 @@
export { Router } from './router.ts';

View File

@@ -0,0 +1,68 @@
import type { Store } from '@/state/store.ts';
import type { AppState, BaseType, ViewType } from '@/types/index.ts';
const VALID_BASES: BaseType[] = [
'hst', 'flg', 'itm', 'loc', 'ply', // Taxonomía
'mst', 'bck', // Maestros
'mth', 'atc', // Registro
'mail', 'chat', // Comunicación
'key', 'mindlink' // Servicios
];
const VALID_VIEWS: ViewType[] = ['grid', 'tree', 'graph'];
export class Router {
private store: Store<AppState>;
private onNavigate: () => void;
constructor(store: Store<AppState>, onNavigate: () => void) {
this.store = store;
this.onNavigate = onNavigate;
window.addEventListener('hashchange', () => this.handleHashChange());
}
parseHash(): void {
const hash = window.location.hash
.replace(/^#\/?/, '')
.replace(/\/?$/, '')
.split('/')
.filter(Boolean);
const state = this.store.getState();
let base = state.base;
let view = state.view;
if (hash[0] && VALID_BASES.includes(hash[0].toLowerCase() as BaseType)) {
base = hash[0].toLowerCase() as BaseType;
}
if (hash[1] && VALID_VIEWS.includes(hash[1].toLowerCase() as ViewType)) {
view = hash[1].toLowerCase() as ViewType;
}
this.store.setState({ base, view });
}
updateHash(): void {
const state = this.store.getState();
const parts: string[] = [state.base];
if (state.view !== 'grid') {
parts.push(state.view);
}
window.location.hash = '/' + parts.join('/') + '/';
}
private handleHashChange(): void {
this.parseHash();
this.onNavigate();
}
navigate(base?: BaseType, view?: ViewType): void {
const state = this.store.getState();
this.store.setState({
base: base ?? state.base,
view: view ?? state.view
});
this.updateHash();
}
}

View File

@@ -0,0 +1,35 @@
import { createStore } from './store.ts';
import type { AppState, EdgeType } from '@/types/index.ts';
import { EDGE_COLORS } from '@/config/index.ts';
const initialState: AppState = {
base: 'hst',
lang: 'es',
view: 'grid',
search: '',
group: 'all',
library: 'all',
libraryMembers: new Set(),
selectionMode: false,
selected: new Set(),
selectedTag: null,
tags: [],
hstTags: [],
groups: [],
libraries: [],
graphEdges: [],
treeEdges: [],
graphFilters: {
cats: new Set(['hst'] as const),
edges: new Set(Object.keys(EDGE_COLORS) as EdgeType[])
},
graphSettings: {
nodeSize: 20,
linkDist: 80,
showImg: true,
showLbl: true
}
};
export const store = createStore(initialState);
export { createStore } from './store.ts';

View File

@@ -0,0 +1,27 @@
type Listener<T> = (state: T, prevState: T) => void;
export interface Store<T extends object> {
getState: () => Readonly<T>;
setState: (partial: Partial<T>) => void;
subscribe: (listener: Listener<T>) => () => void;
}
export function createStore<T extends object>(initialState: T): Store<T> {
let state = { ...initialState };
const listeners = new Set<Listener<T>>();
return {
getState: (): Readonly<T> => state,
setState: (partial: Partial<T>): void => {
const prevState = state;
state = { ...state, ...partial };
listeners.forEach(fn => fn(state, prevState));
},
subscribe: (listener: Listener<T>): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
}
};
}

View File

@@ -0,0 +1,701 @@
/* === RESET & VARIABLES === */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a24;
--border: #2a2a3a;
--text: #e0e0e0;
--text-muted: #888;
--accent: #7c8aff;
--card-width: 176px;
--card-img-height: 176px;
}
html, body { height: 100%; overflow: hidden; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg-secondary); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: #444; }
/* === APP LAYOUT === */
.app { display: flex; flex-direction: column; height: 100vh; }
/* === TOPBAR === */
.topbar {
height: 50px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
}
.topbar-left { display: flex; align-items: center; gap: 10px; }
.topbar-center { flex: 1; display: flex; justify-content: center; gap: 16px; }
.topbar-right { display: flex; align-items: center; gap: 10px; }
.logo { font-weight: 700; font-size: 1.2em; color: var(--accent); letter-spacing: 1px; }
/* === BUTTONS === */
.btn {
padding: 7px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
font-size: 0.8em;
font-weight: 500;
transition: all 0.15s ease;
}
.btn:hover { border-color: var(--accent); color: var(--text); }
.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-sm { padding: 5px 10px; font-size: 0.75em; }
.search-input {
width: 300px;
padding: 9px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.9em;
}
.search-input:focus { outline: none; border-color: var(--accent); }
.search-input::placeholder { color: var(--text-muted); }
.base-buttons { display: flex; gap: 2px; background: var(--bg-card); border-radius: 6px; padding: 3px; }
.base-btn {
padding: 6px 14px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
font-size: 0.8em;
font-weight: 600;
transition: all 0.15s ease;
}
.base-btn:hover { color: var(--text); }
.base-btn.active { background: var(--accent); color: #fff; }
/* === VIEW BAR === */
.view-bar {
height: 40px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
.view-bar-spacer { width: 120px; }
/* === SEL/GET GROUP === */
.sel-group {
display: flex;
align-items: center;
gap: 2px;
background: var(--bg-card);
border-radius: 6px;
padding: 3px;
}
.sel-btn {
padding: 5px 12px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
font-size: 0.75em;
font-weight: 600;
transition: all 0.15s ease;
}
.sel-btn:hover { color: var(--text); background: rgba(255,255,255,0.05); }
.sel-btn.active { background: var(--accent); color: #fff; }
#sel-count {
font-size: 0.7em;
color: var(--accent);
margin-left: 6px;
font-weight: 600;
}
/* === GROUPS BAR === */
.groups-bar {
height: 44px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 8px;
overflow-x: auto;
}
.group-btn {
padding: 6px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 20px;
color: var(--text-muted);
cursor: pointer;
font-size: 0.75em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.15s ease;
}
.group-btn:hover { border-color: var(--accent); color: var(--text); }
.group-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* === MAIN LAYOUT === */
.main-layout { display: flex; flex: 1; overflow: hidden; }
/* === LEFT PANEL === */
.left-panel {
width: 84px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 10px 6px;
flex-shrink: 0;
}
.lib-icon {
width: 68px;
height: 68px;
margin: 6px auto;
border-radius: 10px;
background: var(--bg-card);
border: 2px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
transition: all 0.15s ease;
overflow: hidden;
}
.lib-icon:hover { border-color: var(--accent); }
.lib-icon.active { border-color: var(--accent); background: rgba(124, 138, 255, 0.15); }
.lib-icon img { width: 42px; height: 42px; object-fit: cover; border-radius: 6px; }
.lib-icon span {
font-size: 0.6em;
color: var(--text-muted);
margin-top: 4px;
text-align: center;
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* === CENTER PANEL === */
.center-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* === VIEW TABS === */
.view-tabs { display: flex; gap: 6px; }
.view-tab {
padding: 7px 20px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
font-size: 0.85em;
font-weight: 500;
transition: all 0.15s ease;
}
.view-tab:hover { color: var(--text); background: var(--bg-card); }
.view-tab.active { background: var(--accent); color: #fff; }
/* === CONTENT AREA === */
.content-area { flex: 1; overflow: hidden; position: relative; }
/* === GRID VIEW === */
.grid-view {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 16px;
padding: 20px;
overflow-y: auto;
height: 100%;
}
.card {
width: var(--card-width);
flex-shrink: 0;
flex-grow: 0;
background: var(--bg-card);
border-radius: 10px;
border: 1px solid var(--border);
overflow: hidden;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
position: relative;
}
.card:hover {
border-color: var(--accent);
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0,0,0,0.3);
}
.card.selected {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(124, 138, 255, 0.4);
}
.card-checkbox {
position: absolute;
top: 10px;
left: 10px;
width: 24px;
height: 24px;
border-radius: 6px;
background: rgba(0,0,0,0.7);
border: 2px solid var(--border);
display: none;
align-items: center;
justify-content: center;
z-index: 5;
transition: all 0.15s ease;
}
.card-checkbox.visible { display: flex; }
.card-checkbox.checked { background: var(--accent); border-color: var(--accent); }
.card-checkbox.checked::after { content: "\2713"; color: #fff; font-size: 14px; font-weight: bold; }
.card-image {
width: var(--card-width);
height: var(--card-img-height);
background: linear-gradient(145deg, #1a1a24 0%, #0a0a0f 100%);
position: relative;
overflow: hidden;
}
.card-placeholder {
width: var(--card-width);
height: var(--card-img-height);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5em;
font-weight: 700;
color: var(--accent);
opacity: 0.5;
text-transform: uppercase;
background: linear-gradient(145deg, #1a1a24 0%, #0a0a0f 100%);
}
.card-img {
width: var(--card-width);
height: var(--card-img-height);
object-fit: cover;
}
.card-body { padding: 12px; }
.card-ref {
font-size: 0.75em;
color: var(--accent);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card-name {
font-size: 0.85em;
color: var(--text);
margin-top: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
}
/* === TREE VIEW === */
.tree-view {
padding: 20px;
overflow-y: auto;
height: 100%;
}
.tree-group { margin-bottom: 12px; }
.tree-header {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
border-radius: 8px;
background: var(--bg-card);
transition: background 0.15s ease;
}
.tree-header:hover { background: var(--bg-secondary); }
.tree-toggle {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 1em;
font-weight: bold;
flex-shrink: 0;
}
.tree-group-name { flex: 1; font-weight: 500; }
.tree-count {
font-size: 0.75em;
color: var(--text-muted);
background: var(--bg-secondary);
padding: 4px 10px;
border-radius: 12px;
}
.tree-items { display: none; margin-left: 28px; margin-top: 4px; }
.tree-items.expanded { display: block; }
.tree-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
margin: 3px 0;
gap: 10px;
transition: background 0.15s ease;
}
.tree-item:hover { background: var(--bg-card); }
.tree-item.selected { background: rgba(124,138,255,0.15); }
.tree-img {
width: 32px;
height: 32px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: var(--bg-card);
}
.tree-placeholder {
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--bg-card);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9em;
font-weight: 600;
color: var(--accent);
}
.tree-name { font-size: 0.9em; }
/* === GRAPH VIEW === */
.graph-view {
width: 100%;
height: 100%;
position: relative;
}
.graph-view svg { width: 100%; height: 100%; display: block; background: var(--bg); }
.node { cursor: pointer; }
.node text { fill: var(--text-muted); pointer-events: none; font-size: 11px; }
.node.selected circle { stroke: var(--accent); stroke-width: 4; }
.link { stroke-opacity: 0.5; }
/* === DETAIL PANEL === */
.detail-panel {
width: 0;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
overflow-y: auto;
overflow-x: hidden;
transition: width 0.3s ease;
flex-shrink: 0;
}
.detail-panel.open { width: 360px; }
.detail-header {
position: relative;
width: 100%;
height: 220px;
background: linear-gradient(145deg, var(--bg-card) 0%, var(--bg) 100%);
overflow: hidden;
}
.detail-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 5em;
font-weight: 700;
color: var(--accent);
opacity: 0.4;
text-transform: uppercase;
}
.detail-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail-close {
position: absolute;
top: 12px;
right: 12px;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0,0,0,0.7);
border: none;
color: #fff;
cursor: pointer;
font-size: 20px;
z-index: 5;
transition: background 0.15s ease;
}
.detail-close:hover { background: rgba(0,0,0,0.9); }
.detail-body { padding: 20px; }
.detail-ref {
font-size: 1.2em;
color: var(--accent);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
.detail-mrf {
font-size: 0.7em;
color: var(--text-muted);
margin-top: 8px;
font-family: monospace;
word-break: break-all;
cursor: pointer;
padding: 8px 10px;
background: var(--bg-card);
border-radius: 6px;
transition: color 0.15s ease;
}
.detail-mrf:hover { color: var(--accent); }
.detail-name { font-size: 1.3em; color: var(--text); margin-top: 16px; font-weight: 500; }
.detail-desc { font-size: 0.9em; color: var(--text-muted); margin-top: 12px; line-height: 1.7; }
.detail-section { margin-top: 24px; }
.detail-section h4 {
font-size: 0.75em;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 12px;
font-weight: 600;
letter-spacing: 0.5px;
}
.chip-list { display: flex; flex-wrap: wrap; gap: 8px; }
.tag-chip {
padding: 7px 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8em;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s ease;
}
.tag-chip:hover { border-color: var(--accent); color: var(--text); }
/* === TOAST === */
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
background: var(--accent);
color: #fff;
padding: 14px 28px;
border-radius: 10px;
font-size: 0.9em;
font-weight: 500;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1000;
pointer-events: none;
}
.toast.show { opacity: 1; }
/* === MODAL === */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.85);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal.open { display: flex; }
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 14px;
width: 90%;
max-width: 620px;
max-height: 80vh;
overflow: hidden;
}
.modal-header {
padding: 18px 22px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 { font-size: 1.15em; color: var(--text); font-weight: 600; }
.modal-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1.5em;
padding: 4px 8px;
}
.modal-close:hover { color: var(--text); }
.modal-body { padding: 22px; overflow-y: auto; max-height: calc(80vh - 65px); }
.api-item { margin-bottom: 18px; }
.api-endpoint {
font-family: monospace;
font-size: 0.9em;
color: var(--accent);
background: var(--bg-card);
padding: 12px 14px;
border-radius: 8px;
}
.api-desc { font-size: 0.85em; color: var(--text-muted); margin-top: 8px; }
/* === EMPTY STATE === */
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
gap: 16px;
padding: 40px;
}
/* === LOADING === */
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
gap: 14px;
font-size: 1em;
}
.loading::after {
content: "";
width: 28px;
height: 28px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* === SELECT === */
select {
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-card);
color: var(--text);
font-size: 0.8em;
cursor: pointer;
}
select:focus { outline: none; border-color: var(--accent); }
/* === GRAPH OPTIONS PANEL === */
.graph-options {
padding: 10px;
overflow-y: auto;
width: 180px;
}
.graph-section {
margin-bottom: 16px;
}
.graph-section-title {
font-size: 0.7em;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.graph-stat {
display: flex;
justify-content: space-between;
font-size: 0.75em;
color: var(--text-muted);
margin-bottom: 4px;
}
.graph-stat-value {
color: var(--text);
font-weight: 600;
}
.graph-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75em;
color: var(--text-muted);
margin-bottom: 6px;
cursor: pointer;
}
.graph-checkbox:hover { color: var(--text); }
.graph-checkbox input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
}
.graph-checkbox .color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 4px;
}
.graph-slider {
margin-bottom: 12px;
}
.graph-slider-label {
display: flex;
justify-content: space-between;
font-size: 0.7em;
color: var(--text-muted);
margin-bottom: 4px;
}
.graph-slider-value {
color: var(--text);
font-weight: 600;
}
.graph-slider input[type="range"] {
width: 100%;
height: 4px;
background: var(--border);
border-radius: 2px;
-webkit-appearance: none;
cursor: pointer;
}
.graph-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: var(--accent);
border-radius: 50%;
cursor: pointer;
}
.left-panel.graph-mode {
width: 180px;
}

View File

@@ -0,0 +1,37 @@
export type EdgeType =
| 'relation'
| 'specialization'
| 'mirror'
| 'dependency'
| 'sequence'
| 'composition'
| 'hierarchy'
| 'library'
| 'contextual'
| 'association';
export type CategoryKey = 'hst' | 'spe' | 'vue' | 'vsn' | 'msn' | 'flg';
export interface GraphEdge {
mrf_a: string;
mrf_b: string;
edge_type: EdgeType;
weight?: number;
}
export interface TreeEdge {
mrf_parent: string;
mrf_child: string;
}
export interface GraphNode {
id: string;
ref: string;
name: string;
img: string;
cat: CategoryKey;
x?: number;
y?: number;
fx?: number | null;
fy?: number | null;
}

View File

@@ -0,0 +1,16 @@
export type { Tag, Group, Library, ChildTag, RelatedTag } from './tag.ts';
export type {
EdgeType,
CategoryKey,
GraphEdge,
TreeEdge,
GraphNode
} from './graph.ts';
export type {
ViewType,
BaseType,
LangType,
GraphFilters,
GraphSettings,
AppState
} from './state.ts';

View File

@@ -0,0 +1,53 @@
import type { Tag, Group, Library } from './tag.ts';
import type { GraphEdge, TreeEdge, CategoryKey, EdgeType } from './graph.ts';
export type ViewType = 'grid' | 'tree' | 'graph';
export type BaseType =
| 'hst' | 'flg' | 'itm' | 'loc' | 'ply' // Taxonomía (public)
| 'mth' | 'atc' // Registro (secretaria_clara, production_alfred)
| 'mst' | 'bck' // Maestros (secretaria_clara)
| 'mail' | 'chat' // Comunicación (mail_manager, context_manager)
| 'key' | 'mindlink'; // Servicios
export type LangType = 'es' | 'en' | 'ch';
export interface GraphFilters {
cats: Set<CategoryKey>;
edges: Set<EdgeType>;
}
export interface GraphSettings {
nodeSize: number;
linkDist: number;
showImg: boolean;
showLbl: boolean;
}
export interface AppState {
// Navigation
base: BaseType;
lang: LangType;
view: ViewType;
// Filters
search: string;
group: string;
library: string;
libraryMembers: Set<string>;
// Selection
selectionMode: boolean;
selected: Set<string>;
selectedTag: Tag | null;
// Data
tags: Tag[];
hstTags: Tag[]; // HST tags for group name resolution
groups: Group[];
libraries: Library[];
graphEdges: GraphEdge[];
treeEdges: TreeEdge[];
// Graph-specific
graphFilters: GraphFilters;
graphSettings: GraphSettings;
}

View File

@@ -0,0 +1,42 @@
export interface Tag {
mrf: string;
ref: string;
name_es?: string;
name_en?: string;
name_ch?: string;
txt?: string;
alias?: string;
set_hst?: string;
img_url?: string;
img_thumb_url?: string;
}
export interface Group {
mrf: string;
ref: string;
name_es?: string;
name_en?: string;
}
export interface Library {
mrf: string;
ref?: string;
name?: string;
name_es?: string;
name_en?: string;
alias?: string;
icon_url?: string;
img_thumb_url?: string;
member_count?: number;
}
export interface ChildTag {
mrf: string;
ref?: string;
alias?: string;
name_es?: string;
}
export interface RelatedTag extends ChildTag {
edge_type: string;
}

View File

@@ -0,0 +1,14 @@
import { toast } from './toast.ts';
export async function copyToClipboard(text: string, message?: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
toast(message || 'Copiado');
} catch {
toast('Error al copiar');
}
}
export function copyMrf(mrf: string): void {
copyToClipboard(mrf, `MRF copiado: ${mrf.slice(0, 8)}...`);
}

View File

@@ -0,0 +1,44 @@
export const $ = <T extends HTMLElement>(
selector: string,
parent: ParentNode = document
): T | null => parent.querySelector<T>(selector);
export const $$ = <T extends HTMLElement>(
selector: string,
parent: ParentNode = document
): T[] => Array.from(parent.querySelectorAll<T>(selector));
export function createElement<K extends keyof HTMLElementTagNameMap>(
tag: K,
attrs?: Record<string, string>,
children?: (HTMLElement | string)[]
): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
if (attrs) {
Object.entries(attrs).forEach(([key, value]) => {
if (key === 'className') el.className = value;
else if (key.startsWith('data-')) el.setAttribute(key, value);
else el.setAttribute(key, value);
});
}
if (children) {
children.forEach(child => {
el.append(typeof child === 'string' ? child : child);
});
}
return el;
}
export function delegateEvent<T extends Event>(
container: HTMLElement,
selector: string,
eventType: string,
handler: (event: T, target: HTMLElement) => void
): void {
container.addEventListener(eventType, (event) => {
const target = (event.target as HTMLElement).closest<HTMLElement>(selector);
if (target && container.contains(target)) {
handler(event as T, target);
}
});
}

View File

@@ -0,0 +1,39 @@
import type { Tag, LangType } from '@/types/index.ts';
import { getName } from './i18n.ts';
export interface FilterOptions {
search: string;
group: string;
library: string;
libraryMembers: Set<string>;
lang: LangType;
}
export function filterTags(tags: Tag[], options: FilterOptions): Tag[] {
const { search, group, library, libraryMembers, lang } = options;
const q = search.toLowerCase();
return tags.filter(tag => {
// Library filter
if (library !== 'all' && !libraryMembers.has(tag.mrf)) {
return false;
}
// Group filter
if (group !== 'all' && tag.set_hst !== group) {
return false;
}
// Search filter
if (q) {
const name = getName(tag, lang).toLowerCase();
const ref = (tag.ref || '').toLowerCase();
const alias = (tag.alias || '').toLowerCase();
if (!name.includes(q) && !ref.includes(q) && !alias.includes(q)) {
return false;
}
}
return true;
});
}

View File

@@ -0,0 +1,42 @@
import type { Tag, LangType } from '@/types/index.ts';
export function getName(tag: Tag, lang: LangType): string {
if (lang === 'es' && tag.name_es) return tag.name_es;
if (lang === 'en' && tag.name_en) return tag.name_en;
if (lang === 'ch' && tag.name_ch) return tag.name_ch;
return tag.name_es || tag.name_en || tag.alias || tag.ref || tag.mrf.slice(0, 8);
}
// Create a map of mrf -> display name for resolving groups
export function createNameMap(tags: Tag[], lang: LangType): Map<string, string> {
const map = new Map<string, string>();
tags.forEach(tag => {
map.set(tag.mrf, getName(tag, lang));
});
return map;
}
// Resolve a group mrf to its display name
export function resolveGroupName(mrf: string | undefined, nameMap: Map<string, string>): string {
if (!mrf) return 'Sin grupo';
return nameMap.get(mrf) || mrf.slice(0, 8);
}
const ATC_BASE = 'https://atc.tzzrdeck.me';
function resolveImgUrl(url: string | undefined): string {
if (!url) return '';
// Relative paths (e.g., "thumbs/xxx.png") need ATC base
if (url && !url.startsWith('http')) {
return `${ATC_BASE}/${url}`;
}
return url;
}
export function getImg(tag: Tag): string {
return resolveImgUrl(tag.img_thumb_url);
}
export function getFullImg(tag: Tag): string {
return resolveImgUrl(tag.img_url) || resolveImgUrl(tag.img_thumb_url);
}

View File

@@ -0,0 +1,5 @@
export { $, $$, createElement, delegateEvent } from './dom.ts';
export { getName, getImg, getFullImg, createNameMap, resolveGroupName } from './i18n.ts';
export { filterTags, type FilterOptions } from './filters.ts';
export { copyToClipboard, copyMrf } from './clipboard.ts';
export { toast } from './toast.ts';

View File

@@ -0,0 +1,21 @@
let toastEl: HTMLElement | null = null;
let toastTimeout: number | null = null;
export function toast(message: string, duration = 2000): void {
if (!toastEl) {
toastEl = document.createElement('div');
toastEl.className = 'toast';
document.body.appendChild(toastEl);
}
if (toastTimeout) {
clearTimeout(toastTimeout);
}
toastEl.textContent = message;
toastEl.classList.add('show');
toastTimeout = window.setTimeout(() => {
toastEl?.classList.remove('show');
}, duration);
}

View File

@@ -0,0 +1,115 @@
import { View } from '../View.ts';
import { getName, getFullImg, copyMrf, delegateEvent } from '@/utils/index.ts';
import { fetchChildren, fetchRelated } from '@/api/index.ts';
import type { Store } from '@/state/store.ts';
import type { AppState, Tag } from '@/types/index.ts';
export class DetailPanel extends View {
private panelEl: HTMLElement;
constructor(
container: HTMLElement,
store: Store<AppState>
) {
super(container, store);
this.panelEl = container;
}
async showDetail(mrf: string): Promise<void> {
const state = this.getState();
const tag = state.tags.find(t => t.mrf === mrf);
if (!tag) return;
this.setState({ selectedTag: tag });
this.panelEl.classList.add('open');
await this.renderDetail(tag);
}
close(): void {
this.panelEl.classList.remove('open');
this.setState({ selectedTag: null });
}
render(): void {
const state = this.getState();
if (state.selectedTag) {
this.renderDetail(state.selectedTag);
}
}
private async renderDetail(tag: Tag): Promise<void> {
const state = this.getState();
const img = getFullImg(tag);
const name = getName(tag, state.lang);
this.panelEl.innerHTML = `
<div class="detail-header">
${img
? `<img class="detail-img" src="${img}" alt="${tag.ref}">`
: `<div class="detail-placeholder">${tag.ref?.slice(0, 2) || 'T'}</div>`
}
<button class="detail-close">&times;</button>
</div>
<div class="detail-body">
<div class="detail-ref">${tag.ref || ''}</div>
<div class="detail-mrf" data-mrf="${tag.mrf}">${tag.mrf}</div>
<div class="detail-name">${name}</div>
<div class="detail-desc">${tag.txt || tag.alias || ''}</div>
<div id="children-section" class="detail-section" style="display:none">
<h4>Hijos</h4>
<div id="children-list" class="chip-list"></div>
</div>
<div id="related-section" class="detail-section" style="display:none">
<h4>Relacionados</h4>
<div id="related-list" class="chip-list"></div>
</div>
</div>
`;
this.bindDetailEvents();
await this.loadRelations(tag.mrf);
}
private bindDetailEvents(): void {
const closeBtn = this.panelEl.querySelector('.detail-close');
closeBtn?.addEventListener('click', () => this.close());
const mrfEl = this.panelEl.querySelector('.detail-mrf');
mrfEl?.addEventListener('click', () => {
const mrf = (mrfEl as HTMLElement).dataset.mrf;
if (mrf) copyMrf(mrf);
});
delegateEvent<MouseEvent>(this.panelEl, '.tag-chip', 'click', (_, target) => {
const mrf = target.dataset.mrf;
if (mrf) this.showDetail(mrf);
});
}
private async loadRelations(mrf: string): Promise<void> {
const [children, related] = await Promise.all([
fetchChildren(mrf),
fetchRelated(mrf)
]);
const childrenSection = this.panelEl.querySelector('#children-section') as HTMLElement;
const childrenList = this.panelEl.querySelector('#children-list') as HTMLElement;
if (children.length > 0) {
childrenSection.style.display = 'block';
childrenList.innerHTML = children.map(c => {
const label = c.name_es || c.alias || c.ref || c.mrf.slice(0, 8);
return `<span class="tag-chip" data-mrf="${c.mrf}">${label}</span>`;
}).join('');
}
const relatedSection = this.panelEl.querySelector('#related-section') as HTMLElement;
const relatedList = this.panelEl.querySelector('#related-list') as HTMLElement;
if (related.length > 0) {
relatedSection.style.display = 'block';
relatedList.innerHTML = related.map(r => {
const label = r.name_es || r.alias || r.ref || r.mrf.slice(0, 8);
return `<span class="tag-chip" data-mrf="${r.mrf}" title="${r.edge_type}">${label}</span>`;
}).join('');
}
}
}

View File

@@ -0,0 +1,249 @@
import { View } from '../View.ts';
import { filterTags, getName, getImg } from '@/utils/index.ts';
import { fetchGraphEdges, fetchTreeEdges } from '@/api/index.ts';
import { CATS, EDGE_COLORS } from '@/config/index.ts';
import type { Store } from '@/state/store.ts';
import type { AppState, GraphNode, CategoryKey, EdgeType } from '@/types/index.ts';
type D3Module = typeof import('d3');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type D3Selection = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type D3Simulation = any;
export class GraphView extends View {
private d3: D3Module | null = null;
private simulation: D3Simulation | null = null;
private showDetail: (mrf: string) => void;
constructor(
container: HTMLElement,
store: Store<AppState>,
showDetail: (mrf: string) => void
) {
super(container, store);
this.showDetail = showDetail;
}
async mount(): Promise<void> {
this.container.innerHTML = '<div class="loading">Cargando grafo...</div>';
// Lazy load D3
if (!this.d3) {
this.d3 = await import('d3');
}
// Load graph data
const state = this.getState();
if (state.graphEdges.length === 0) {
const [graphEdges, treeEdges] = await Promise.all([
fetchGraphEdges(),
fetchTreeEdges()
]);
this.store.setState({ graphEdges, treeEdges });
}
this.render();
}
render(): void {
if (!this.d3) return;
const d3 = this.d3;
const state = this.getState();
// Build nodes from filtered tags
const filtered = filterTags(state.tags, {
search: state.search,
group: state.group,
library: state.library,
libraryMembers: state.libraryMembers,
lang: state.lang
});
const nodeMap = new Map<string, GraphNode>();
filtered.forEach(tag => {
nodeMap.set(tag.mrf, {
id: tag.mrf,
ref: tag.alias || tag.ref || tag.mrf.slice(0, 8),
name: getName(tag, state.lang),
img: getImg(tag),
cat: 'hst' as CategoryKey
});
});
// Build edges
interface GraphLink {
source: string | GraphNode;
target: string | GraphNode;
type: EdgeType;
weight: number;
}
const edges: GraphLink[] = [];
state.graphEdges.forEach(e => {
if (nodeMap.has(e.mrf_a) && nodeMap.has(e.mrf_b)) {
if (state.graphFilters.edges.has(e.edge_type)) {
edges.push({
source: e.mrf_a,
target: e.mrf_b,
type: e.edge_type,
weight: e.weight || 1
});
}
}
});
state.treeEdges.forEach(e => {
if (nodeMap.has(e.mrf_parent) && nodeMap.has(e.mrf_child)) {
if (state.graphFilters.edges.has('hierarchy')) {
edges.push({
source: e.mrf_parent,
target: e.mrf_child,
type: 'hierarchy',
weight: 1
});
}
}
});
const nodes = Array.from(nodeMap.values());
if (nodes.length === 0) {
this.container.innerHTML = '<div class="empty">Sin nodos para mostrar</div>';
return;
}
// Clear and create SVG
this.container.innerHTML = '';
const width = this.container.clientWidth;
const height = this.container.clientHeight || 600;
const svg: D3Selection = d3.select(this.container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${width} ${height}`);
const g = svg.append('g');
// Zoom
const zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event: { transform: string }) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
// Simulation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.simulation = d3.forceSimulation(nodes as any)
.force('link', d3.forceLink(edges)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.id((d: any) => d.id)
.distance(state.graphSettings.linkDist))
.force('charge', d3.forceManyBody().strength(-150))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(state.graphSettings.nodeSize + 5));
// Links
const link = g.append('g')
.selectAll('line')
.data(edges)
.join('line')
.attr('stroke', (d: GraphLink) => EDGE_COLORS[d.type] || '#999')
.attr('stroke-width', (d: GraphLink) => Math.sqrt(d.weight))
.attr('stroke-opacity', 0.6);
// Nodes
const node = g.append('g')
.selectAll('g')
.data(nodes)
.join('g')
.attr('cursor', 'pointer')
.call(this.createDrag(d3, this.simulation));
const nodeSize = state.graphSettings.nodeSize;
if (state.graphSettings.showImg) {
node.append('image')
.attr('xlink:href', (d: GraphNode) => d.img || '')
.attr('width', nodeSize)
.attr('height', nodeSize)
.attr('x', -nodeSize / 2)
.attr('y', -nodeSize / 2)
.attr('clip-path', 'circle(50%)');
// Fallback for nodes without image
node.filter((d: GraphNode) => !d.img)
.append('circle')
.attr('r', nodeSize / 2)
.attr('fill', (d: GraphNode) => CATS[d.cat]?.color || '#7c8aff');
} else {
node.append('circle')
.attr('r', nodeSize / 2)
.attr('fill', (d: GraphNode) => CATS[d.cat]?.color || '#7c8aff');
}
if (state.graphSettings.showLbl) {
node.append('text')
.text((d: GraphNode) => d.ref)
.attr('dy', nodeSize / 2 + 12)
.attr('text-anchor', 'middle')
.attr('font-size', 10)
.attr('fill', 'var(--text-primary)');
}
node.on('click', (_: MouseEvent, d: GraphNode) => {
if (state.selectionMode) {
const newSelected = new Set(state.selected);
if (newSelected.has(d.id)) {
newSelected.delete(d.id);
} else {
newSelected.add(d.id);
}
this.store.setState({ selected: newSelected });
} else {
this.showDetail(d.id);
}
});
// Tick
this.simulation.on('tick', () => {
link
.attr('x1', (d: GraphLink) => (d.source as GraphNode).x!)
.attr('y1', (d: GraphLink) => (d.source as GraphNode).y!)
.attr('x2', (d: GraphLink) => (d.target as GraphNode).x!)
.attr('y2', (d: GraphLink) => (d.target as GraphNode).y!);
node.attr('transform', (d: GraphNode) => `translate(${d.x},${d.y})`);
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private createDrag(d3: D3Module, simulation: D3Simulation): any {
return d3.drag()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.on('start', (event: any, d: any) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.on('drag', (event: any, d: any) => {
d.fx = event.x;
d.fy = event.y;
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.on('end', (event: any, d: any) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
});
}
unmount(): void {
this.simulation?.stop();
super.unmount();
}
}

View File

@@ -0,0 +1,75 @@
import { View } from '../View.ts';
import { filterTags, getName, getImg, delegateEvent } from '@/utils/index.ts';
import type { Store } from '@/state/store.ts';
import type { AppState } from '@/types/index.ts';
export class GridView extends View {
private showDetail: (mrf: string) => void;
constructor(
container: HTMLElement,
store: Store<AppState>,
showDetail: (mrf: string) => void
) {
super(container, store);
this.showDetail = showDetail;
}
render(): void {
const state = this.getState();
const filtered = filterTags(state.tags, {
search: state.search,
group: state.group,
library: state.library,
libraryMembers: state.libraryMembers,
lang: state.lang
});
if (filtered.length === 0) {
this.container.innerHTML = '<div class="empty">Sin resultados</div>';
return;
}
this.container.innerHTML = filtered.map(tag => {
const img = getImg(tag);
const name = getName(tag, state.lang);
const isSelected = state.selected.has(tag.mrf);
return `
<div class="card ${isSelected ? 'selected' : ''}" data-mrf="${tag.mrf}">
${state.selectionMode ? `
<input type="checkbox" class="card-checkbox" ${isSelected ? 'checked' : ''}>
` : ''}
${img
? `<img class="card-img" src="${img}" alt="${tag.ref}" loading="lazy">`
: `<div class="card-placeholder">${tag.ref?.slice(0, 2) || 'T'}</div>`
}
<div class="card-name">${name}</div>
</div>
`;
}).join('');
this.bindEvents();
}
private bindEvents(): void {
const state = this.getState();
delegateEvent<MouseEvent>(this.container, '.card', 'click', (_, target) => {
const mrf = target.dataset.mrf;
if (!mrf) return;
if (state.selectionMode) {
const newSelected = new Set(state.selected);
if (newSelected.has(mrf)) {
newSelected.delete(mrf);
} else {
newSelected.add(mrf);
}
this.setState({ selected: newSelected });
} else {
this.showDetail(mrf);
}
});
}
}

View File

@@ -0,0 +1,109 @@
import { View } from '../View.ts';
import { filterTags, getName, getImg, delegateEvent, createNameMap, resolveGroupName } from '@/utils/index.ts';
import type { Store } from '@/state/store.ts';
import type { AppState, Tag } from '@/types/index.ts';
export class TreeView extends View {
private showDetail: (mrf: string) => void;
private expanded: Set<string> = new Set();
constructor(
container: HTMLElement,
store: Store<AppState>,
showDetail: (mrf: string) => void
) {
super(container, store);
this.showDetail = showDetail;
}
render(): void {
const state = this.getState();
// Use hstTags for group name resolution (set_hst points to hst tags)
const nameMap = createNameMap(state.hstTags, state.lang);
const filtered = filterTags(state.tags, {
search: state.search,
group: state.group,
library: state.library,
libraryMembers: state.libraryMembers,
lang: state.lang
});
// Group by set_hst
const groups = new Map<string, Tag[]>();
filtered.forEach(tag => {
const group = tag.set_hst || 'sin-grupo';
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(tag);
});
if (groups.size === 0) {
this.container.innerHTML = '<div class="empty">Sin resultados</div>';
return;
}
this.container.innerHTML = Array.from(groups.entries()).map(([groupMrf, tags]) => {
const isExpanded = this.expanded.has(groupMrf);
const groupName = resolveGroupName(groupMrf === 'sin-grupo' ? undefined : groupMrf, nameMap);
return `
<div class="tree-group">
<div class="tree-header" data-group="${groupMrf}">
<span class="tree-toggle">${isExpanded ? '' : '+'}</span>
<span class="tree-group-name">${groupName}</span>
<span class="tree-count">${tags.length}</span>
</div>
<div class="tree-items ${isExpanded ? 'expanded' : ''}">
${tags.map(tag => {
const img = getImg(tag);
const name = getName(tag, state.lang);
const isSelected = state.selected.has(tag.mrf);
return `
<div class="tree-item ${isSelected ? 'selected' : ''}" data-mrf="${tag.mrf}">
${img
? `<img class="tree-img" src="${img}" alt="${tag.ref}">`
: `<div class="tree-placeholder">${tag.ref?.slice(0, 1) || 'T'}</div>`
}
<span class="tree-name">${name}</span>
</div>
`;
}).join('')}
</div>
</div>
`;
}).join('');
this.bindEvents();
}
private bindEvents(): void {
const state = this.getState();
delegateEvent<MouseEvent>(this.container, '.tree-header', 'click', (_, target) => {
const group = target.dataset.group;
if (!group) return;
if (this.expanded.has(group)) {
this.expanded.delete(group);
} else {
this.expanded.add(group);
}
this.render();
});
delegateEvent<MouseEvent>(this.container, '.tree-item', 'click', (_, target) => {
const mrf = target.dataset.mrf;
if (!mrf) return;
if (state.selectionMode) {
const newSelected = new Set(state.selected);
if (newSelected.has(mrf)) {
newSelected.delete(mrf);
} else {
newSelected.add(mrf);
}
this.setState({ selected: newSelected });
} else {
this.showDetail(mrf);
}
});
}
}

View File

@@ -0,0 +1,33 @@
import type { Store } from '@/state/store.ts';
import type { AppState } from '@/types/index.ts';
export abstract class View {
protected container: HTMLElement;
protected store: Store<AppState>;
protected unsubscribe?: () => void;
constructor(container: HTMLElement, store: Store<AppState>) {
this.container = container;
this.store = store;
}
abstract render(): void;
mount(): void {
this.unsubscribe = this.store.subscribe(() => this.render());
this.render();
}
unmount(): void {
this.unsubscribe?.();
this.container.innerHTML = '';
}
protected getState(): AppState {
return this.store.getState();
}
protected setState(partial: Partial<AppState>): void {
this.store.setState(partial);
}
}

View File

@@ -0,0 +1,5 @@
export { View } from './View.ts';
export { GridView } from './GridView/GridView.ts';
export { TreeView } from './TreeView/TreeView.ts';
export { GraphView } from './GraphView/GraphView.ts';
export { DetailPanel } from './DetailPanel/DetailPanel.ts';