Files
osint_web/frontend/src/views/TablesView.tsx
T
Egutierrez 881a1b9716 feat: frontend React+Mantine+sigma.js (grafo/tablas/fichas/agenda/calendario)
Frontend web de lectura del vault osint + agenda/calendario Xandikos.

- Stack: React 19 + Vite 6 + TypeScript + Mantine v9 (React 19 obligatorio para
  que Mantine v9 monte). Grafo con sigma v3 + graphology + forceatlas2 en web
  worker. Markdown con react-markdown, calendario con @mantine/dates.
- AppShell con navbar de 4 secciones + botón global de refresco (POST /api/refresh).
- GraphView: force-directed, color por tipo, tamaño por grado, panel lateral con
  toggles de tipo + dangling + buscador (centra el nodo). Guard de WebGL: si el
  navegador no lo expone, avisa en vez de crashear.
- TablesView: una pestaña por tipo, tabla ordenable/filtrable con columnas del
  frontmatter. Click en fila -> ficha.
- NodeCard (modal): frontmatter clave-valor (fechas europeas), cuerpo Markdown,
  galería de imágenes con lightbox, PDFs/docs como enlace, wikilinks navegables.
- ContactsView: agenda con buscador + detalle (teléfonos, correos, bloque osint,
  nota). CalendarView: mini-calendario con días marcados + eventos agrupados por
  día (hora local).
- Vite proxya /api -> 127.0.0.1:8470. Verificado end-to-end contra el backend
  real: 1199 nodos / 618 aristas, 539 personas en tabla, 1064 contactos, 98
  eventos; grafo renderiza con WebGL y NodeCard abre con frontmatter+body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:15:21 +02:00

290 lines
8.2 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Center,
Group,
Loader,
ScrollArea,
Table,
Tabs,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import {
IconChevronDown,
IconChevronUp,
IconSearch,
IconSelector,
} from "@tabler/icons-react";
import { fetchGraph, fetchNodes, type NodeRow } from "../api";
import { formatFrontmatterValue } from "../format";
import { tipoStyle } from "../tipos";
import { useNodeCard } from "../NodeCardContext";
// Una pestaña por tipo de nodo real (no fantasma). Cada pestaña carga
// perezosamente sus filas de /api/nodes?tipo=<t> y las muestra en una tabla
// Mantine ordenable + filtrable. Las columnas se deducen de las claves de
// frontmatter más comunes del tipo.
// Tipos que tienen tabla propia (los nodos reales del vault). Orden de aparición.
const TABLE_TIPOS = [
"persona",
"organizacion",
"lugar",
"documento",
"caso",
"dominio",
];
export function TablesView() {
const [availableTipos, setAvailableTipos] = useState<string[]>([]);
const [active, setActive] = useState<string>("persona");
const [error, setError] = useState<string | null>(null);
// Determinar qué tipos existen realmente en el vault (de los counts del grafo).
useEffect(() => {
let alive = true;
fetchGraph()
.then((d) => {
if (!alive) return;
const present = TABLE_TIPOS.filter((t) => (d.counts[t] ?? 0) > 0);
// Añadir cualquier otro tipo real con nodos no contemplado arriba.
const extra = Object.keys(d.counts).filter(
(t) => (d.counts[t] ?? 0) > 0 && !TABLE_TIPOS.includes(t) && t !== "nota",
);
const all = [...present, ...extra];
setAvailableTipos(all);
if (all.length > 0 && !all.includes(active)) setActive(all[0]);
})
.catch((e) => alive && setError(String(e)));
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (error) {
return (
<Center h="100%" p="xl">
<Alert color="red" title="No se pudieron cargar las tablas">
{error}
</Alert>
</Center>
);
}
if (availableTipos.length === 0) {
return (
<Center h="100%">
<Loader />
</Center>
);
}
return (
<Tabs
value={active}
onChange={(v) => v && setActive(v)}
keepMounted={false}
h="100%"
style={{ display: "flex", flexDirection: "column" }}
>
<Tabs.List px="md" pt="xs">
{availableTipos.map((t) => (
<Tabs.Tab
key={t}
value={t}
leftSection={
<Box
w={8}
h={8}
style={{
borderRadius: "50%",
background: tipoStyle(t).color,
}}
/>
}
>
{tipoStyle(t).label}
</Tabs.Tab>
))}
</Tabs.List>
{availableTipos.map((t) => (
<Tabs.Panel
key={t}
value={t}
style={{ flex: 1, minHeight: 0, display: "flex" }}
>
{active === t && <TypeTable tipo={t} />}
</Tabs.Panel>
))}
</Tabs>
);
}
type SortDir = "asc" | "desc" | null;
function TypeTable({ tipo }: { tipo: string }) {
const { open } = useNodeCard();
const [rows, setRows] = useState<NodeRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [sortCol, setSortCol] = useState<string>("label");
const [sortDir, setSortDir] = useState<SortDir>("asc");
useEffect(() => {
let alive = true;
setLoading(true);
fetchNodes(tipo)
.then((d) => alive && setRows(d.rows))
.catch((e) => alive && setError(String(e)))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [tipo]);
// Columnas: las claves de frontmatter más frecuentes de este conjunto, con
// `label` (nombre del nodo) siempre primero. Limitado para no desbordar.
const columns = useMemo(() => {
const freq: Record<string, number> = {};
for (const r of rows) {
for (const k of Object.keys(r.frontmatter)) {
if (k === "nombre" || k === "tipo") continue; // redundantes con label/tab
freq[k] = (freq[k] ?? 0) + 1;
}
}
const ranked = Object.entries(freq)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([k]) => k);
return ["label", ...ranked];
}, [rows]);
const cellValue = (row: NodeRow, col: string): string => {
if (col === "label") return row.label;
return formatFrontmatterValue(row.frontmatter[col]);
};
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
let out = rows;
if (q) {
out = rows.filter((r) =>
columns.some((c) => cellValue(r, c).toLowerCase().includes(q)),
);
}
if (sortDir) {
out = [...out].sort((a, b) => {
const va = cellValue(a, sortCol).toLowerCase();
const vb = cellValue(b, sortCol).toLowerCase();
if (va < vb) return sortDir === "asc" ? -1 : 1;
if (va > vb) return sortDir === "asc" ? 1 : -1;
return 0;
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, filter, sortCol, sortDir, columns]);
function toggleSort(col: string) {
if (sortCol !== col) {
setSortCol(col);
setSortDir("asc");
} else {
setSortDir((d) => (d === "asc" ? "desc" : d === "desc" ? null : "asc"));
}
}
if (error) {
return (
<Alert color="red" title="Error" m="md">
{error}
</Alert>
);
}
return (
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Group p="md" pb="xs" justify="space-between">
<TextInput
placeholder={`Filtrar ${tipoStyle(tipo).label.toLowerCase()}`}
leftSection={<IconSearch size={16} />}
value={filter}
onChange={(e) => setFilter(e.currentTarget.value)}
w={300}
/>
<Text size="sm" c="dimmed">
{filtered.length} de {rows.length}
</Text>
</Group>
{loading ? (
<Center style={{ flex: 1 }}>
<Loader />
</Center>
) : (
<ScrollArea style={{ flex: 1 }} px="md">
<Table striped highlightOnHover stickyHeader withTableBorder>
<Table.Thead>
<Table.Tr>
{columns.map((c) => (
<Table.Th key={c}>
<UnstyledButton onClick={() => toggleSort(c)}>
<Group gap={4} wrap="nowrap">
<Text fw={600} size="sm" tt="capitalize">
{c === "label" ? "nombre" : c.replace(/_/g, " ")}
</Text>
<SortIcon active={sortCol === c} dir={sortDir} />
</Group>
</UnstyledButton>
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((r) => (
<Table.Tr
key={r.id}
style={{ cursor: "pointer" }}
onClick={() => open(r.id)}
>
{columns.map((c) => (
<Table.Td key={c}>
<Text size="sm" lineClamp={2}>
{cellValue(r, c)}
</Text>
</Table.Td>
))}
</Table.Tr>
))}
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={columns.length}>
<Text c="dimmed" ta="center" py="md">
Sin resultados
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</Box>
);
}
function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
if (!active || dir === null) return <IconSelector size={14} opacity={0.5} />;
return dir === "asc" ? (
<IconChevronUp size={14} />
) : (
<IconChevronDown size={14} />
);
}