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>
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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} />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user