881a1b9716
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>
290 lines
8.2 KiB
TypeScript
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} />
|
|
);
|
|
}
|