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,220 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Group,
|
||||
Indicator,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import { IconClock, IconMapPin } from "@tabler/icons-react";
|
||||
import { fetchCalendar, type CalendarEvent } from "../api";
|
||||
import {
|
||||
dayLabel,
|
||||
formatICalTime,
|
||||
icalDayKey,
|
||||
} from "../format";
|
||||
|
||||
// Calendario: mini-calendario de @mantine/dates a la izquierda (con punto en
|
||||
// los días que tienen eventos) y la lista de eventos a la derecha. Por defecto
|
||||
// muestra el mes actual agrupado por día; al elegir un día se filtra a ese día.
|
||||
|
||||
export function CalendarView() {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Mantine v9 Calendar usa fechas como string "YYYY-MM-DD" (DateStringValue),
|
||||
// no Date. `month` controla el mes mostrado; `selectedDay` filtra a un día.
|
||||
const [month, setMonth] = useState<string>(dayjs().format("YYYY-MM-DD"));
|
||||
const [selectedDay, setSelectedDay] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
fetchCalendar()
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
if (d.status !== "ok") {
|
||||
setError(d.error || "Xandikos no respondió");
|
||||
return;
|
||||
}
|
||||
setEvents(d.events ?? []);
|
||||
})
|
||||
.catch((e) => alive && setError(String(e)))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Eventos indexados por día local "AAAA-MM-DD".
|
||||
const byDay = useMemo(() => {
|
||||
const map = new Map<string, CalendarEvent[]>();
|
||||
for (const e of events) {
|
||||
const key = icalDayKey(e.dtstart);
|
||||
if (!key) continue;
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(e);
|
||||
map.set(key, list);
|
||||
}
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
// Días visibles: si hay día seleccionado, solo ese; si no, todos los del mes
|
||||
// mostrado, ordenados.
|
||||
const visibleDays = useMemo(() => {
|
||||
const monthPrefix = month.slice(0, 7); // "YYYY-MM"
|
||||
let keys = [...byDay.keys()];
|
||||
if (selectedDay) {
|
||||
keys = keys.filter((k) => k === selectedDay);
|
||||
} else {
|
||||
keys = keys.filter((k) => k.startsWith(monthPrefix));
|
||||
}
|
||||
return keys.sort();
|
||||
}, [byDay, month, selectedDay]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center h="100%" p="xl">
|
||||
<Alert color="orange" title="Calendario no disponible" maw={500}>
|
||||
{error}
|
||||
<Text size="sm" mt="xs" c="dimmed">
|
||||
El calendario viene del servidor Xandikos. El resto de la app (grafo,
|
||||
tablas) funciona sin él.
|
||||
</Text>
|
||||
</Alert>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
|
||||
<Paper
|
||||
p="md"
|
||||
radius={0}
|
||||
withBorder
|
||||
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0 }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Calendar
|
||||
date={month}
|
||||
onDateChange={setMonth}
|
||||
getDayProps={(date) => ({
|
||||
selected: selectedDay === date,
|
||||
onClick: () =>
|
||||
setSelectedDay((prev) => (prev === date ? null : date)),
|
||||
})}
|
||||
renderDay={(date) => {
|
||||
const has = byDay.has(date);
|
||||
const day = Number(date.slice(8, 10));
|
||||
return (
|
||||
<Indicator
|
||||
size={6}
|
||||
color="brand"
|
||||
offset={-2}
|
||||
disabled={!has}
|
||||
>
|
||||
<div>{day}</div>
|
||||
</Indicator>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{events.length} eventos en total
|
||||
</Text>
|
||||
{selectedDay && (
|
||||
<Badge
|
||||
variant="light"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setSelectedDay(null)}
|
||||
>
|
||||
Ver todo el mes
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<ScrollArea h="100%">
|
||||
<Stack p="xl" gap="lg" maw={760}>
|
||||
<Title order={3}>
|
||||
{selectedDay
|
||||
? dayLabel(selectedDay)
|
||||
: dayjs(month).format("MMMM YYYY")}
|
||||
</Title>
|
||||
|
||||
{visibleDays.length === 0 && (
|
||||
<Text c="dimmed">Sin eventos en este periodo.</Text>
|
||||
)}
|
||||
|
||||
{visibleDays.map((day) => (
|
||||
<Stack key={day} gap="xs">
|
||||
{!selectedDay && (
|
||||
<Text fw={600} size="sm" c="brand">
|
||||
{dayLabel(day)}
|
||||
</Text>
|
||||
)}
|
||||
{(byDay.get(day) ?? [])
|
||||
.sort((a, b) =>
|
||||
(a.dtstart ?? "").localeCompare(b.dtstart ?? ""),
|
||||
)
|
||||
.map((ev, i) => (
|
||||
<EventRow key={(ev.uid ?? "") + i} ev={ev} />
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ ev }: { ev: CalendarEvent }) {
|
||||
return (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{ev.summary || "(sin título)"}
|
||||
</Text>
|
||||
{ev.location && (
|
||||
<Group gap={4} mt={2}>
|
||||
<IconMapPin size={13} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{ev.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{ev.description && (
|
||||
<Text size="xs" c="dimmed" mt={4} lineClamp={2}>
|
||||
{ev.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconClock size={13} />
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatICalTime(ev.dtstart)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
IconAt,
|
||||
IconNote,
|
||||
IconPhone,
|
||||
IconSearch,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { fetchContacts, type Contact } from "../api";
|
||||
|
||||
// Agenda: lista de contactos del addressbook Xandikos a la izquierda (con
|
||||
// buscador por nombre / alias / teléfono / email) y la ficha del contacto
|
||||
// seleccionado a la derecha (todos los campos, incluido el bloque osint y nota).
|
||||
|
||||
export function ContactsView() {
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<Contact | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [debQuery] = useDebouncedValue(query, 200);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
fetchContacts()
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
if (d.status !== "ok") {
|
||||
setError(d.error || "Xandikos no respondió");
|
||||
return;
|
||||
}
|
||||
setContacts(d.contacts ?? []);
|
||||
})
|
||||
.catch((e) => alive && setError(String(e)))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debQuery.trim().toLowerCase();
|
||||
if (!q) return contacts;
|
||||
return contacts.filter((c) => {
|
||||
const hay = [
|
||||
c.nombre,
|
||||
c.alias,
|
||||
c.org,
|
||||
...(c.telefonos ?? []),
|
||||
...(c.correos ?? []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}, [contacts, debQuery]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center h="100%" p="xl">
|
||||
<Alert color="orange" title="Agenda no disponible" maw={500}>
|
||||
{error}
|
||||
<Text size="sm" mt="xs" c="dimmed">
|
||||
El calendario y los contactos vienen del servidor Xandikos. El resto
|
||||
de la app (grafo, tablas) funciona sin él.
|
||||
</Text>
|
||||
</Alert>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
|
||||
<Paper
|
||||
w={360}
|
||||
radius={0}
|
||||
withBorder
|
||||
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0, display: "flex" }}
|
||||
>
|
||||
<Stack gap={0} w="100%">
|
||||
<Box p="md" pb="xs">
|
||||
<TextInput
|
||||
placeholder="Buscar contacto…"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
{filtered.length} de {contacts.length} contactos
|
||||
</Text>
|
||||
</Box>
|
||||
<Divider />
|
||||
{loading ? (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack gap={0}>
|
||||
{filtered.map((c, i) => {
|
||||
const key = c.uid || c.href || String(i);
|
||||
const isSel = selected === c;
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
px="md"
|
||||
py="xs"
|
||||
onClick={() => setSelected(c)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: isSel
|
||||
? "var(--mantine-color-brand-light)"
|
||||
: undefined,
|
||||
borderBottom: "1px solid var(--mantine-color-dark-5)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={isSel ? 600 : 400} truncate>
|
||||
{c.nombre || c.alias || c.uid || "(sin nombre)"}
|
||||
</Text>
|
||||
{(c.telefonos?.[0] || c.correos?.[0]) && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{c.telefonos?.[0] || c.correos?.[0]}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<ScrollArea h="100%">
|
||||
{selected ? (
|
||||
<ContactDetail contact={selected} />
|
||||
) : (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconUser size={48} opacity={0.3} />
|
||||
<Text c="dimmed">Selecciona un contacto</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactDetail({ contact }: { contact: Contact }) {
|
||||
const osintEntries = Object.entries(contact.osint ?? {}).filter(
|
||||
([, v]) => v != null && v !== "",
|
||||
);
|
||||
return (
|
||||
<Stack p="xl" gap="lg" maw={720}>
|
||||
<Group gap="sm">
|
||||
<Title order={3}>
|
||||
{contact.nombre || contact.alias || contact.uid || "(sin nombre)"}
|
||||
</Title>
|
||||
{contact.alias && contact.alias !== contact.nombre && (
|
||||
<Badge variant="light" color="gray">
|
||||
{contact.alias}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{contact.org && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{contact.org}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(contact.telefonos?.length ?? 0) > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<IconPhone size={16} />
|
||||
<Text fw={600} size="sm">
|
||||
Teléfonos
|
||||
</Text>
|
||||
</Group>
|
||||
{contact.phones.map((p, i) => (
|
||||
<Group key={i} gap="xs" pl="lg">
|
||||
<Text size="sm">{p.value}</Text>
|
||||
{p.type && (
|
||||
<Badge size="xs" variant="outline" color="gray">
|
||||
{p.type}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{(contact.correos?.length ?? 0) > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<IconAt size={16} />
|
||||
<Text fw={600} size="sm">
|
||||
Correos
|
||||
</Text>
|
||||
</Group>
|
||||
{contact.emails.map((e, i) => (
|
||||
<Group key={i} gap="xs" pl="lg">
|
||||
<Text size="sm">{e.value}</Text>
|
||||
{e.type && (
|
||||
<Badge size="xs" variant="outline" color="gray">
|
||||
{e.type}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{osintEntries.length > 0 && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text fw={600} size="sm" mb="xs" c="brand">
|
||||
OSINT
|
||||
</Text>
|
||||
<Table withRowBorders={false} verticalSpacing={4}>
|
||||
<Table.Tbody>
|
||||
{osintEntries.map(([k, v]) => (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Td w={140}>
|
||||
<Text size="sm" c="dimmed" tt="capitalize">
|
||||
{k.replace(/_/g, " ")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{v}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{contact.nota && (
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<IconNote size={16} />
|
||||
<Text fw={600} size="sm">
|
||||
Nota
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }} pl="lg">
|
||||
{contact.nota}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{contact.uid && (
|
||||
<Text size="xs" c="dimmed">
|
||||
UID: {contact.uid}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
IconSearch,
|
||||
IconZoomReset,
|
||||
} from "@tabler/icons-react";
|
||||
import Graph from "graphology";
|
||||
import forceAtlas2 from "graphology-layout-forceatlas2";
|
||||
import FA2Layout from "graphology-layout-forceatlas2/worker";
|
||||
import Sigma from "sigma";
|
||||
import { fetchGraph, fetchSearch, type GraphPayload } from "../api";
|
||||
import { useNodeCard } from "../NodeCardContext";
|
||||
import { DANGLING_COLOR, edgeColor, tipoStyle } from "../tipos";
|
||||
|
||||
// Layout: forceatlas2 en un web worker (no bloquea la UI con 1199 nodos). Se
|
||||
// arranca al montar, se deja correr unos segundos y el usuario puede pausar /
|
||||
// reanudar. Un pre-cálculo síncrono acotado da posiciones iniciales decentes.
|
||||
|
||||
export function GraphView() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const sigmaRef = useRef<Sigma | null>(null);
|
||||
const graphRef = useRef<Graph | null>(null);
|
||||
const layoutRef = useRef<FA2Layout | null>(null);
|
||||
const { open } = useNodeCard();
|
||||
|
||||
const [payload, setPayload] = useState<GraphPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [visibleTipos, setVisibleTipos] = useState<Set<string>>(new Set());
|
||||
const [showDangling, setShowDangling] = useState(true);
|
||||
const [running, setRunning] = useState(true);
|
||||
// Error de renderizado del propio canvas (ej. WebGL no disponible: navegador
|
||||
// sin GPU / headless). Se aísla aquí para no tumbar la app entera.
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [debSearch] = useDebouncedValue(search, 300);
|
||||
const [matches, setMatches] = useState<{ id: string; label: string; tipo: string }[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
// --- carga de datos ---
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
fetchGraph()
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
setPayload(d);
|
||||
setVisibleTipos(new Set(Object.keys(d.counts)));
|
||||
})
|
||||
.catch((e) => alive && setError(String(e)))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tipos = useMemo(
|
||||
() => (payload ? Object.keys(payload.counts).sort() : []),
|
||||
[payload],
|
||||
);
|
||||
|
||||
// --- construcción del grafo + sigma ---
|
||||
useEffect(() => {
|
||||
if (!payload || !containerRef.current) return;
|
||||
|
||||
// Pre-chequeo de WebGL: sigma necesita un contexto WebGL. Si el navegador no
|
||||
// lo expone (sin GPU, headless sin --use-gl), avisamos en vez de crashear.
|
||||
const probe = document.createElement("canvas");
|
||||
const gl =
|
||||
probe.getContext("webgl2") || probe.getContext("webgl");
|
||||
if (!gl) {
|
||||
setRenderError(
|
||||
"Este navegador no expone WebGL, necesario para dibujar el grafo. " +
|
||||
"Las demás vistas (tablas, contactos, calendario) funcionan igual.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const graph = new Graph({ multi: true, type: "undirected" });
|
||||
const degree: Record<string, number> = {};
|
||||
for (const e of payload.edges) {
|
||||
degree[e.source] = (degree[e.source] ?? 0) + 1;
|
||||
degree[e.target] = (degree[e.target] ?? 0) + 1;
|
||||
}
|
||||
|
||||
for (const n of payload.nodes) {
|
||||
const deg = degree[n.id] ?? 0;
|
||||
const size = Math.min(3 + Math.sqrt(deg) * 2, 18);
|
||||
graph.addNode(n.id, {
|
||||
label: n.label,
|
||||
size,
|
||||
color: n.dangling ? DANGLING_COLOR : tipoStyle(n.tipo).color,
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
tipo: n.tipo,
|
||||
dangling: !!n.dangling,
|
||||
});
|
||||
}
|
||||
for (const e of payload.edges) {
|
||||
if (graph.hasNode(e.source) && graph.hasNode(e.target)) {
|
||||
graph.addEdge(e.source, e.target, {
|
||||
color: edgeColor(e.kind),
|
||||
size: 0.6,
|
||||
kind: e.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Posiciones iniciales: pre-cálculo síncrono acotado (no bloquea mucho).
|
||||
forceAtlas2.assign(graph, {
|
||||
iterations: 50,
|
||||
settings: forceAtlas2.inferSettings(graph),
|
||||
});
|
||||
|
||||
graphRef.current = graph;
|
||||
let sigma: Sigma;
|
||||
try {
|
||||
sigma = new Sigma(graph, containerRef.current, {
|
||||
renderLabels: true,
|
||||
labelDensity: 0.6,
|
||||
labelGridCellSize: 80,
|
||||
defaultEdgeColor: "#343a40",
|
||||
labelColor: { color: "#c1c2c5" },
|
||||
labelFont: "Inter, sans-serif",
|
||||
});
|
||||
} catch (err) {
|
||||
setRenderError(
|
||||
"No se pudo inicializar el lienzo del grafo (WebGL): " + String(err),
|
||||
);
|
||||
graphRef.current = null;
|
||||
return;
|
||||
}
|
||||
sigmaRef.current = sigma;
|
||||
|
||||
sigma.on("clickNode", ({ node }) => open(node));
|
||||
|
||||
// Layout continuo en worker.
|
||||
const layout = new FA2Layout(graph, {
|
||||
settings: forceAtlas2.inferSettings(graph),
|
||||
});
|
||||
layoutRef.current = layout;
|
||||
layout.start();
|
||||
setRunning(true);
|
||||
// Frenar el layout automáticamente a los 8s para que se estabilice.
|
||||
const stopTimer = window.setTimeout(() => {
|
||||
layout.stop();
|
||||
setRunning(false);
|
||||
}, 8000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(stopTimer);
|
||||
layout.kill();
|
||||
sigma.kill();
|
||||
sigmaRef.current = null;
|
||||
graphRef.current = null;
|
||||
layoutRef.current = null;
|
||||
};
|
||||
}, [payload, open]);
|
||||
|
||||
// --- aplicar filtros de visibilidad (tipos + dangling) ---
|
||||
useEffect(() => {
|
||||
const graph = graphRef.current;
|
||||
const sigma = sigmaRef.current;
|
||||
if (!graph || !sigma) return;
|
||||
graph.forEachNode((id, attrs) => {
|
||||
const dangling = attrs.dangling as boolean;
|
||||
const tipo = attrs.tipo as string;
|
||||
const hidden = (dangling && !showDangling) || !visibleTipos.has(tipo);
|
||||
graph.setNodeAttribute(id, "hidden", hidden);
|
||||
});
|
||||
sigma.refresh();
|
||||
}, [visibleTipos, showDangling]);
|
||||
|
||||
// --- búsqueda contra /api/search ---
|
||||
useEffect(() => {
|
||||
if (!debSearch.trim()) {
|
||||
setMatches([]);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
fetchSearch(debSearch)
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setMatches(
|
||||
r.results
|
||||
.filter((m) => graphRef.current?.hasNode(m.id))
|
||||
.slice(0, 20)
|
||||
.map((m) => ({ id: m.id, label: m.label, tipo: m.tipo })),
|
||||
);
|
||||
})
|
||||
.catch(() => alive && setMatches([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [debSearch]);
|
||||
|
||||
const centerOn = useCallback((id: string) => {
|
||||
const sigma = sigmaRef.current;
|
||||
const graph = graphRef.current;
|
||||
if (!sigma || !graph || !graph.hasNode(id)) return;
|
||||
const nodePos = sigma.getNodeDisplayData(id);
|
||||
if (!nodePos) return;
|
||||
sigma.getCamera().animate(
|
||||
{ x: nodePos.x, y: nodePos.y, ratio: 0.25 },
|
||||
{ duration: 500 },
|
||||
);
|
||||
}, []);
|
||||
|
||||
function toggleTipo(tipo: string) {
|
||||
setVisibleTipos((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(tipo)) next.delete(tipo);
|
||||
else next.add(tipo);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleLayout() {
|
||||
const layout = layoutRef.current;
|
||||
if (!layout) return;
|
||||
if (running) {
|
||||
layout.stop();
|
||||
setRunning(false);
|
||||
} else {
|
||||
layout.start();
|
||||
setRunning(true);
|
||||
}
|
||||
}
|
||||
|
||||
function resetCamera() {
|
||||
sigmaRef.current?.getCamera().animatedReset();
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center h="100%" p="xl">
|
||||
<Alert color="red" title="No se pudo cargar el grafo">
|
||||
{error}
|
||||
</Alert>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderError) {
|
||||
return (
|
||||
<Center h="100%" p="xl">
|
||||
<Alert color="orange" title="Grafo no disponible" maw={520}>
|
||||
{renderError}
|
||||
</Alert>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
|
||||
<Paper
|
||||
w={280}
|
||||
p="md"
|
||||
radius={0}
|
||||
withBorder
|
||||
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0 }}
|
||||
>
|
||||
<Stack gap="md" h="100%">
|
||||
<TextInput
|
||||
placeholder="Buscar nodo…"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
{matches.length > 0 && (
|
||||
<ScrollArea.Autosize mah={180}>
|
||||
<Stack gap={4}>
|
||||
{matches.map((m) => (
|
||||
<Button
|
||||
key={m.id}
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
justify="flex-start"
|
||||
leftSection={
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
color={tipoStyle(m.tipo).color}
|
||||
variant="filled"
|
||||
>
|
||||
{" "}
|
||||
</Badge>
|
||||
}
|
||||
onClick={() => centerOn(m.id)}
|
||||
>
|
||||
<Text size="sm" truncate>
|
||||
{m.label}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
|
||||
<Text fw={600} size="sm">
|
||||
Tipos visibles
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah="40vh">
|
||||
<Stack gap={6}>
|
||||
{tipos.map((t) => {
|
||||
const st = tipoStyle(t);
|
||||
return (
|
||||
<Checkbox
|
||||
key={t}
|
||||
checked={visibleTipos.has(t)}
|
||||
onChange={() => toggleTipo(t)}
|
||||
color={st.color}
|
||||
label={
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
color={st.color}
|
||||
variant="filled"
|
||||
>
|
||||
{" "}
|
||||
</Badge>
|
||||
<Text size="sm">{st.label}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{payload?.counts[t]}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
<Switch
|
||||
checked={showDangling}
|
||||
onChange={(e) => setShowDangling(e.currentTarget.checked)}
|
||||
label="Nodos fantasma (dangling)"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Group gap="xs" mt="auto">
|
||||
<Tooltip label={running ? "Pausar layout" : "Reanudar layout"}>
|
||||
<ActionIcon variant="light" onClick={toggleLayout}>
|
||||
{running ? (
|
||||
<IconPlayerPause size={18} />
|
||||
) : (
|
||||
<IconPlayerPlay size={18} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Centrar vista">
|
||||
<ActionIcon variant="light" onClick={resetCamera}>
|
||||
<IconZoomReset size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Text size="xs" c="dimmed">
|
||||
{payload?.total_nodes} nodos · {payload?.total_edges} aristas
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<div style={{ flex: 1, position: "relative" }}>
|
||||
{loading && (
|
||||
<Center
|
||||
style={{ position: "absolute", inset: 0, zIndex: 2 }}
|
||||
bg="rgba(0,0,0,0.3)"
|
||||
>
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
<div ref={containerRef} className="sigma-container" />
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Image, Modal } from "@mantine/core";
|
||||
|
||||
// Lightbox mínimo: un modal grande, fondo oscuro, con la imagen a tamaño
|
||||
// completo. Usa el Modal de Mantine (no librería externa, KISS).
|
||||
|
||||
export function Lightbox({
|
||||
src,
|
||||
onClose,
|
||||
}: {
|
||||
src: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
opened={src !== null}
|
||||
onClose={onClose}
|
||||
size="auto"
|
||||
centered
|
||||
withCloseButton={false}
|
||||
padding={0}
|
||||
styles={{ content: { background: "transparent", boxShadow: "none" } }}
|
||||
>
|
||||
{src && (
|
||||
<Image
|
||||
src={src}
|
||||
fit="contain"
|
||||
mah="85vh"
|
||||
maw="90vw"
|
||||
radius="md"
|
||||
onClick={onClose}
|
||||
style={{ cursor: "zoom-out" }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconExternalLink, IconFile, IconPhoto } from "@tabler/icons-react";
|
||||
import Markdown from "react-markdown";
|
||||
import {
|
||||
attachmentUrl,
|
||||
fetchNode,
|
||||
type Attachment,
|
||||
type NodeDetail,
|
||||
} from "../api";
|
||||
import { formatFrontmatterValue } from "../format";
|
||||
import { tipoStyle } from "../tipos";
|
||||
import { Lightbox } from "./Lightbox";
|
||||
|
||||
interface Props {
|
||||
slug: string | null;
|
||||
onClose: () => void;
|
||||
/** Navegar a otro nodo (click en un wikilink dentro de la ficha). */
|
||||
onNavigate: (slug: string) => void;
|
||||
}
|
||||
|
||||
export function NodeCard({ slug, onClose, onNavigate }: Props) {
|
||||
const [detail, setDetail] = useState<NodeDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightbox, setLightbox] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
setDetail(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchNode(slug)
|
||||
.then((d) => alive && setDetail(d))
|
||||
.catch((e) => alive && setError(String(e)))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
const images = detail?.attachments.filter((a) => a.kind === "image") ?? [];
|
||||
const docs = detail?.attachments.filter((a) => a.kind !== "image") ?? [];
|
||||
|
||||
const style = detail ? tipoStyle(detail.tipo) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={slug !== null}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
title={
|
||||
detail ? (
|
||||
<Group gap="sm">
|
||||
<Title order={4}>{detail.label}</Title>
|
||||
{style && (
|
||||
<Badge color={style.color} variant="light">
|
||||
{style.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
"Ficha"
|
||||
)
|
||||
}
|
||||
>
|
||||
{loading && (
|
||||
<Center p="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert color="red" title="No se pudo cargar la ficha">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{detail && !loading && (
|
||||
<Stack gap="lg">
|
||||
<FrontmatterTable frontmatter={detail.frontmatter} />
|
||||
|
||||
{detail.tags.length > 0 && (
|
||||
<Group gap="xs">
|
||||
{detail.tags.map((t) => (
|
||||
<Badge key={t} variant="dot" color="gray">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{images.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<IconPhoto size={16} />
|
||||
<Text fw={600}>Imágenes ({images.length})</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, md: 4 }} spacing="sm">
|
||||
{images.map((a) => (
|
||||
<Image
|
||||
key={a.path}
|
||||
src={attachmentUrl(a.path)}
|
||||
radius="md"
|
||||
h={140}
|
||||
fit="cover"
|
||||
alt={a.name}
|
||||
style={{ cursor: "zoom-in" }}
|
||||
onClick={() => setLightbox(attachmentUrl(a.path))}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{docs.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<IconFile size={16} />
|
||||
<Text fw={600}>Documentos ({docs.length})</Text>
|
||||
</Group>
|
||||
<Stack gap={4}>
|
||||
{docs.map((a) => (
|
||||
<DocLink key={a.name + a.path} att={a} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{detail.body.trim() && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<div className="node-body">
|
||||
<Markdown
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<Anchor href={href ?? undefined} target="_blank">
|
||||
{children}
|
||||
</Anchor>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{detail.body}
|
||||
</Markdown>
|
||||
</div>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{detail.wikilinks.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="sm" c="dimmed">
|
||||
Enlaces a otras notas
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{detail.wikilinks.map((w) => (
|
||||
<Badge
|
||||
key={w}
|
||||
variant="outline"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => onNavigate(w)}
|
||||
>
|
||||
{w}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Lightbox src={lightbox} onClose={() => setLightbox(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FrontmatterTable({
|
||||
frontmatter,
|
||||
}: {
|
||||
frontmatter: Record<string, unknown>;
|
||||
}) {
|
||||
const entries = Object.entries(frontmatter).filter(
|
||||
([, v]) => v != null && v !== "",
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<Table withRowBorders={false} verticalSpacing={4} striped>
|
||||
<Table.Tbody>
|
||||
{entries.map(([k, v]) => (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Td w={180}>
|
||||
<Text fw={600} size="sm" c="dimmed" tt="capitalize">
|
||||
{k.replace(/_/g, " ")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{formatFrontmatterValue(v)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function DocLink({ att }: { att: Attachment }) {
|
||||
if (att.kind === "missing") {
|
||||
return (
|
||||
<Text size="sm" c="red.5">
|
||||
⚠ {att.name} (no encontrado)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Anchor href={attachmentUrl(att.path)} target="_blank" size="sm">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconExternalLink size={14} />
|
||||
<Text size="sm" span>
|
||||
{att.name.split("/").pop()}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{att.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
@@ -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