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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user