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:
Egutierrez
2026-06-11 23:15:21 +02:00
parent 59558d43cb
commit 881a1b9716
26 changed files with 4619 additions and 39 deletions
+220
View File
@@ -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>
);
}
+281
View File
@@ -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>
);
}
+399
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+248
View File
@@ -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>
);
}
+289
View File
@@ -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} />
);
}