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>
);
}