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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
*.local
.DS_Store
+4
View File
@@ -0,0 +1,4 @@
# pnpm 10 no construye dependencias con scripts de instalación por defecto.
# esbuild (dep transitiva de vite) necesita su postinstall para descargar su
# binario; sin esto, `vite build` falla. Allowlist explícita y mínima.
enable-pre-post-scripts=true
+61 -39
View File
@@ -1,52 +1,74 @@
# Frontend de osint_web (pendiente)
# Frontend de osint_web
Este directorio es un **placeholder**. El frontend lo montará un agente
posterior. El backend (`../server/main.py`) ya está completo y sirve todos los
endpoints que el frontend consumirá.
Frontend web local del explorador OSINT. Lee el backend FastAPI (`../server/main.py`,
escucha solo en `127.0.0.1:8470`) y ofrece cinco vistas de lectura sobre el vault
de Obsidian `osint` + la agenda/calendario del servidor Xandikos.
## Stack previsto
## Stack
- **React + Vite + Mantine v9 + `@fn_library`** (el sistema de UI del registry,
alias a `frontend/functions/ui/`). Componentes propios de `@fn_library` antes
que HTML nativo (regla `frontend_theming.md`): `Group`, `Stack`, `Table`,
`Paper`, `AppShell`, etc., e iconos de `@tabler/icons-react`. Theming con
`createTheme()` de `@mantine/core` (sin Tailwind, sin CSS variables custom).
- **Grafo**: `sigma.js` + `graphology` + `graphology-layout-forceatlas2` (las
dos únicas dependencias nuevas fuera de `@fn_library`; KISS). Color por
`tipo`, tamaño por grado, layout force-directed. Click en nodo → `NodeCard`.
Panel lateral con toggles de tipos visibles y caja de búsqueda.
- **Fechas** en formato europeo `DD/MM/AAAA` (memoria `formato-fecha-europeo`),
incluido el parseo de las fechas iCal (`YYYYMMDD[THHMMSS[Z]]`) que devuelve
`/api/calendar`.
- **React 19 + Vite 6 + TypeScript + Mantine v9**. Mantine v9 exige React 19 (con
React 18 compila pero no monta — error "s is not a function"); por eso el
`package.json` pina React 19. Iconos de `@tabler/icons-react`. Theming con
`createTheme()` (`src/theme.ts`), sin Tailwind ni CSS variables custom (regla
`frontend_theming.md`).
- **Grafo**: `sigma` (v3) + `graphology` + `graphology-layout-forceatlas2` (las
únicas deps fuera de Mantine; KISS). Layout force-directed en un **web worker**
(no bloquea la UI con 1199 nodos), pausable.
- **Markdown** de las fichas con `react-markdown`. **Calendario** con
`@mantine/dates` (que usa `dayjs`).
- Vite proxya `/api``http://127.0.0.1:8470` en dev (sobrescribible con
`VITE_API_BASE`).
## Vistas a construir
## Vistas
| Vista | Fuente (endpoint) | Qué muestra |
|---|---|---|
| `views/GraphView.tsx` | `GET /api/graph` | grafo sigma.js; nodos coloreados por `tipo` usando `counts` para la leyenda; nodos `dangling` atenuados con toggle; búsqueda con `GET /api/search?q=`. |
| `views/TablesView.tsx` | `GET /api/nodes?tipo=<tipo>` | una pestaña/tabla Mantine por tipo (persona, organizacion, lugar, dominio, caso) con columnas del `frontmatter`, ordenable y filtrable. |
| `views/NodeCard.tsx` | `GET /api/node/<slug>` | ficha: `frontmatter` clave-valor + `body` Markdown + galería de `attachments` (imágenes con lightbox vía `GET /api/attachment?path=`, PDFs como enlace). |
| `views/ContactsView.tsx` | `GET /api/contacts`, `GET /api/contact/<uid>` | lista/tabla de contactos del addressbook Xandikos (nombre, teléfonos, emails, org, nota). |
| `views/CalendarView.tsx` | `GET /api/calendar?from=&to=` | eventos del calendario Xandikos en un rango (summary, fechas en formato europeo, lugar, descripción). |
| Vista | Archivo | Endpoint(s) | Qué muestra |
|---|---|---|---|
| **Grafo** | `src/views/GraphView.tsx` | `GET /api/graph`, `GET /api/search` | sigma.js force-directed; color por `tipo`, tamaño por grado; panel lateral con toggles de tipos + dangling + buscador (centra el nodo). Click en nodo → ficha. Layout pausable + reset de cámara. |
| **Tablas** | `src/views/TablesView.tsx` | `GET /api/graph`, `GET /api/nodes?tipo=` | una pestaña por tipo real; `Table` Mantine con columnas deducidas del frontmatter, ordenable y filtrable. Click en fila → ficha. |
| **Ficha** | `src/views/NodeCard.tsx` | `GET /api/node/<slug>`, `GET /api/attachment` | modal: frontmatter clave-valor (fechas europeas DD/MM/AAAA), cuerpo Markdown, galería de imágenes con lightbox, documentos/PDFs como enlace, wikilinks navegables. |
| **Contactos** | `src/views/ContactsView.tsx` | `GET /api/contacts` | agenda: lista + buscador (nombre/alias/tel/email); detalle con teléfonos, correos, bloque `osint` (dni/país/sexo…) y nota. |
| **Calendario** | `src/views/CalendarView.tsx` | `GET /api/calendar` | mini-calendario `@mantine/dates` con punto en días con eventos + lista de eventos del mes/día agrupados por fecha (hora local, lugar, descripción). |
## Cómo se montará (cuando se haga)
Botón global **Refrescar** (header) → `POST /api/refresh` + recarga de la vista activa.
## Arrancar (dev)
Necesitas backend + frontend a la vez:
```bash
# Terminal 1 — backend (escucha solo en 127.0.0.1)
cd projects/osint/apps/osint_web
.venv/bin/python server/main.py --vault /home/enmanuel/Obsidian/osint --port 8470
# Terminal 2 — frontend
cd projects/osint/apps/osint_web/frontend
pnpm install # primera vez
pnpm dev # http://127.0.0.1:5173
```
Abrir **http://127.0.0.1:5173**. El proxy de Vite reenvía `/api` al backend, así
que no hay que tocar CORS. Solo localhost (datos sensibles del vault: DNIs, fotos).
## Build
```bash
cd projects/osint/apps/osint_web/frontend
pnpm create vite . --template react-ts # o el scaffolder del registry
pnpm add @mantine/core @mantine/hooks @tabler/icons-react sigma graphology graphology-layout-forceatlas2
# alias @fn_library -> ../../../../../frontend/functions/ui en vite.config.ts
pnpm dev # http://127.0.0.1:5173 (proxy /api -> http://127.0.0.1:8470)
pnpm install
pnpm build # tsc -b && vite build → dist/
```
Configurar el proxy de Vite (`server.proxy`) para reenviar `/api` al backend en
`http://127.0.0.1:8470`, o leer la URL del backend de una env var
(`VITE_API_BASE`). El backend ya emite CORS abierto solo en localhost, así que
ambos enfoques funcionan.
### Gotcha pnpm 10/11 (esbuild)
## Arrancar el backend (necesario para desarrollar el frontend)
pnpm bloquea por seguridad los scripts de build de dependencias. `esbuild` (el
bundler nativo de Vite) necesita su `postinstall`. El `pnpm-workspace.yaml` lo
permite con `allowBuilds: { esbuild: true }`. Si `pnpm build` falla con
"esbuild ... was not found", ejecuta `pnpm rebuild esbuild`.
```bash
cd projects/osint/apps/osint_web
.venv/bin/python server/main.py --vault ~/Obsidian/osint --port 8470
```
## Notas
- **Grafo sin WebGL**: si el navegador no expone WebGL (headless sin GPU), la vista
Grafo muestra un aviso en vez de crashear; el resto de la app sigue funcionando.
- **Contactos/Calendario** dependen del servidor Xandikos: si no responde, esas dos
vistas muestran un aviso naranja y el grafo/tablas siguen operativos (offline).
- Las fechas se presentan en **europeo** (`src/format.ts`): ISO de Obsidian
`2026-06-07``07/06/2026`; iCal `20220829T133000Z` → hora local `15:30`.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osint · explorador</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{
"name": "osint-web-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@mantine/notifications": "^9.3.0",
"@tabler/icons-react": "^3.36.0",
"dayjs": "^1.11.13",
"graphology": "^0.26.0",
"graphology-layout-forceatlas2": "^0.10.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"sigma": "^3.0.2"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^4.3.4",
"postcss": "^8.4.49",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "~5.6.3",
"vite": "^6.0.3"
}
}
+2344
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
plugins: {
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
"mantine-breakpoint-xs": "36em",
"mantine-breakpoint-sm": "48em",
"mantine-breakpoint-md": "62em",
"mantine-breakpoint-lg": "75em",
"mantine-breakpoint-xl": "88em",
},
},
},
};
+125
View File
@@ -0,0 +1,125 @@
import { useState } from "react";
import {
ActionIcon,
AppShell,
Badge,
Group,
NavLink,
ScrollArea,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconAddressBook,
IconCalendarEvent,
IconGraph,
IconRefresh,
IconTable,
} from "@tabler/icons-react";
import { refresh } from "./api";
import { NodeCardProvider } from "./NodeCardContext";
import { GraphView } from "./views/GraphView";
import { TablesView } from "./views/TablesView";
import { ContactsView } from "./views/ContactsView";
import { CalendarView } from "./views/CalendarView";
type Section = "grafo" | "tablas" | "contactos" | "calendario";
const SECTIONS: {
key: Section;
label: string;
icon: typeof IconGraph;
}[] = [
{ key: "grafo", label: "Grafo", icon: IconGraph },
{ key: "tablas", label: "Tablas", icon: IconTable },
{ key: "contactos", label: "Contactos", icon: IconAddressBook },
{ key: "calendario", label: "Calendario", icon: IconCalendarEvent },
];
export function App() {
const [section, setSection] = useState<Section>("grafo");
// Cambiar `reloadKey` fuerza el remontaje de la vista activa tras un refresh
// del backend, para que vuelva a pedir sus datos.
const [reloadKey, setReloadKey] = useState(0);
const [refreshing, setRefreshing] = useState(false);
async function onRefresh() {
setRefreshing(true);
try {
const res = await refresh();
notifications.show({
color: "teal",
title: "Vault recargado",
message: `${res.nodes} nodos, ${res.edges} aristas`,
});
setReloadKey((k) => k + 1);
} catch (err) {
notifications.show({
color: "red",
title: "Error al refrescar",
message: String(err),
});
} finally {
setRefreshing(false);
}
}
return (
<NodeCardProvider>
<AppShell
header={{ height: 56 }}
navbar={{ width: 220, breakpoint: "sm" }}
padding={0}
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group gap="xs">
<IconGraph size={24} />
<Title order={4}>osint · explorador</Title>
<Badge variant="light" color="gray" size="sm">
solo lectura
</Badge>
</Group>
<Tooltip label="Re-escanear el vault y recargar">
<ActionIcon
variant="light"
size="lg"
onClick={onRefresh}
loading={refreshing}
aria-label="Refrescar"
>
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
</Group>
</AppShell.Header>
<AppShell.Navbar p="xs">
<ScrollArea>
{SECTIONS.map((s) => (
<NavLink
key={s.key}
active={section === s.key}
label={s.label}
leftSection={<s.icon size={18} />}
onClick={() => setSection(s.key)}
/>
))}
</ScrollArea>
<Text size="xs" c="dimmed" p="xs" mt="auto">
datos sensibles · 127.0.0.1
</Text>
</AppShell.Navbar>
<AppShell.Main h="calc(100dvh - 56px)">
{section === "grafo" && <GraphView key={`g-${reloadKey}`} />}
{section === "tablas" && <TablesView key={`t-${reloadKey}`} />}
{section === "contactos" && <ContactsView key={`c-${reloadKey}`} />}
{section === "calendario" && <CalendarView key={`cal-${reloadKey}`} />}
</AppShell.Main>
</AppShell>
</NodeCardProvider>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { NodeCard } from "./views/NodeCard";
// Contexto global para abrir la ficha (NodeCard) de cualquier nodo desde
// cualquier vista (click en nodo del grafo, click en fila de tabla, click en
// resultado de búsqueda). La ficha es un modal único montado en la raíz.
interface NodeCardCtx {
open: (slug: string) => void;
}
const Ctx = createContext<NodeCardCtx | null>(null);
export function NodeCardProvider({ children }: { children: ReactNode }) {
const [slug, setSlug] = useState<string | null>(null);
return (
<Ctx.Provider value={{ open: setSlug }}>
{children}
<NodeCard slug={slug} onClose={() => setSlug(null)} onNavigate={setSlug} />
</Ctx.Provider>
);
}
export function useNodeCard(): NodeCardCtx {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useNodeCard fuera de NodeCardProvider");
return ctx;
}
+172
View File
@@ -0,0 +1,172 @@
// Cliente fino sobre el backend FastAPI de osint_web (127.0.0.1:8470, proxyado
// en dev por Vite bajo /api). Todas las rutas son relativas para que el mismo
// build sirva embebido o tras el proxy. Cada función devuelve JSON ya tipado.
const BASE = "/api";
async function getJSON<T>(path: string): Promise<T> {
const res = await fetch(BASE + path, { headers: { Accept: "application/json" } });
if (!res.ok) {
let detail = "";
try {
const body = await res.json();
detail = body?.detail || body?.error || "";
} catch {
/* respuesta no JSON */
}
throw new Error(`HTTP ${res.status}${detail ? `${detail}` : ""}`);
}
return res.json() as Promise<T>;
}
// --- Tipos del backend ----------------------------------------------------
export type Frontmatter = Record<string, unknown>;
export interface GraphNode {
id: string;
tipo: string;
label: string;
frontmatter: Frontmatter;
dangling?: boolean;
}
export interface GraphEdge {
source: string;
target: string;
kind: string;
}
export interface GraphPayload {
nodes: GraphNode[];
edges: GraphEdge[];
counts: Record<string, number>;
total_nodes: number;
total_edges: number;
}
export interface NodeRow {
id: string;
label: string;
tipo: string;
frontmatter: Frontmatter;
}
export interface NodesPayload {
tipo: string;
count: number;
rows: NodeRow[];
}
export type AttachmentKind = "image" | "pdf" | "other" | "missing";
export interface Attachment {
name: string;
path: string;
kind: AttachmentKind;
}
export interface NodeDetail {
id: string;
tipo: string;
label: string;
frontmatter: Frontmatter;
body: string;
tags: string[];
wikilinks: string[];
attachments: Attachment[];
}
export interface SearchHit {
id: string;
label: string;
tipo: string;
matches: string[];
}
export interface SearchPayload {
query: string;
count: number;
results: SearchHit[];
}
export interface ContactPhone {
value: string;
type: string;
}
export interface Contact {
uid: string | null;
fn: string | null;
nombre: string | null;
nickname: string | null;
alias: string | null;
org: string | null;
note: string | null;
nota: string | null;
phones: ContactPhone[];
emails: ContactPhone[];
telefonos: string[];
correos: string[];
osint: Record<string, string>;
href?: string;
etag?: string;
}
export interface ContactsPayload {
status: string;
count?: number;
contacts?: Contact[];
error?: string;
}
export interface CalendarEvent {
uid: string | null;
summary: string | null;
dtstart: string | null;
dtend: string | null;
location: string | null;
description: string | null;
href?: string;
etag?: string;
}
export interface CalendarPayload {
status: string;
count?: number;
events?: CalendarEvent[];
error?: string;
}
// --- Endpoints ------------------------------------------------------------
export const fetchGraph = () => getJSON<GraphPayload>("/graph");
export const fetchNodes = (tipo: string) =>
getJSON<NodesPayload>(`/nodes?tipo=${encodeURIComponent(tipo)}`);
export const fetchNode = (slug: string) =>
getJSON<NodeDetail>(`/node/${encodeURIComponent(slug)}`);
export const fetchSearch = (q: string) =>
getJSON<SearchPayload>(`/search?q=${encodeURIComponent(q)}`);
export const fetchContacts = () => getJSON<ContactsPayload>("/contacts");
export const fetchCalendar = (from = "", to = "") => {
const qs = new URLSearchParams();
if (from) qs.set("from", from);
if (to) qs.set("to", to);
const tail = qs.toString();
return getJSON<CalendarPayload>(`/calendar${tail ? `?${tail}` : ""}`);
};
export const refresh = () =>
fetch(`${BASE}/refresh`, { method: "POST" }).then((r) => {
if (!r.ok) throw new Error(`refresh falló: HTTP ${r.status}`);
return r.json();
});
// URL del binario de un attachment (imagen / pdf) servido por el backend.
export const attachmentUrl = (path: string) =>
`${BASE}/attachment?path=${encodeURIComponent(path)}`;
+152
View File
@@ -0,0 +1,152 @@
// Formateo de fechas en europeo DD/MM/AAAA (memoria formato-fecha-europeo).
// El backend devuelve dos formas de fecha:
// - ISO de Obsidian: "2026-06-07" (frontmatter `creado`, `fecha_nacimiento`).
// - iCal del calendario: "20260611T090000Z" o "20260611" (date / date-time).
// Estos helpers las normalizan a la presentación europea sin atar al locale.
const MESES = [
"ene",
"feb",
"mar",
"abr",
"may",
"jun",
"jul",
"ago",
"sep",
"oct",
"nov",
"dic",
];
/** "2026-06-07" → "07/06/2026". Devuelve el original si no matchea. */
export function formatISODate(value: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
if (!m) return value;
return `${m[3]}/${m[2]}/${m[1]}`;
}
interface ICalParts {
year: string;
month: string;
day: string;
hour?: string;
minute?: string;
isUtc: boolean;
dateOnly: boolean;
}
/** Parsea "20260611T090000Z" / "20260611" a sus componentes. null si no matchea. */
export function parseICal(value: string): ICalParts | null {
const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?/.exec(value);
if (!m) return null;
return {
year: m[1],
month: m[2],
day: m[3],
hour: m[4],
minute: m[5],
isUtc: m[7] === "Z",
dateOnly: m[4] === undefined,
};
}
/**
* Fecha iCal a europeo. Las date-time vienen en UTC ("...Z"); las convertimos a
* hora local para mostrar la hora correcta del evento. "20260611T090000Z" en
* Europe/Madrid → "11/06/2026 11:00".
*/
export function formatICalDate(value: string | null | undefined): string {
if (!value) return "";
const p = parseICal(value);
if (!p) return value;
if (p.dateOnly) {
return `${p.day}/${p.month}/${p.year}`;
}
if (p.isUtc) {
const d = new Date(
Date.UTC(
Number(p.year),
Number(p.month) - 1,
Number(p.day),
Number(p.hour ?? "0"),
Number(p.minute ?? "0"),
),
);
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${d.getFullYear()} ${hh}:${min}`;
}
return `${p.day}/${p.month}/${p.year} ${p.hour}:${p.minute}`;
}
/** Solo la hora local "HH:MM" de una fecha iCal (para listas agrupadas por día). */
export function formatICalTime(value: string | null | undefined): string {
if (!value) return "";
const p = parseICal(value);
if (!p || p.dateOnly) return "todo el día";
if (p.isUtc) {
const d = new Date(
Date.UTC(
Number(p.year),
Number(p.month) - 1,
Number(p.day),
Number(p.hour ?? "0"),
Number(p.minute ?? "0"),
),
);
return `${String(d.getHours()).padStart(2, "0")}:${String(
d.getMinutes(),
).padStart(2, "0")}`;
}
return `${p.hour}:${p.minute}`;
}
/** Clave de día local "AAAA-MM-DD" de una fecha iCal, para agrupar eventos. */
export function icalDayKey(value: string | null | undefined): string {
if (!value) return "";
const p = parseICal(value);
if (!p) return "";
if (p.dateOnly || !p.isUtc) {
return `${p.year}-${p.month}-${p.day}`;
}
const d = new Date(
Date.UTC(
Number(p.year),
Number(p.month) - 1,
Number(p.day),
Number(p.hour ?? "0"),
Number(p.minute ?? "0"),
),
);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
d.getDate(),
).padStart(2, "0")}`;
}
/** Etiqueta legible de un día "AAAA-MM-DD" → "11 jun 2026". */
export function dayLabel(key: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key);
if (!m) return key;
const mes = MESES[Number(m[2]) - 1] ?? m[2];
return `${Number(m[3])} ${mes} ${m[1]}`;
}
/**
* Presenta un valor de frontmatter como texto. Aplana arrays y objetos, y
* formatea a europeo cualquier string que parezca fecha ISO.
*/
export function formatFrontmatterValue(value: unknown): string {
if (value == null) return "";
if (Array.isArray(value)) {
return value.map((v) => formatFrontmatterValue(v)).join(", ");
}
if (typeof value === "object") {
return JSON.stringify(value);
}
const s = String(value);
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return formatISODate(s);
return s;
}
+36
View File
@@ -0,0 +1,36 @@
html,
body,
#root {
height: 100%;
margin: 0;
}
/* El contenedor del grafo sigma necesita una altura explícita para renderizar. */
.sigma-container {
width: 100%;
height: 100%;
}
/* Markdown del cuerpo de las fichas: márgenes contenidos, imágenes responsivas. */
.node-body img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
.node-body pre {
overflow-x: auto;
padding: 8px;
background: var(--mantine-color-dark-8);
border-radius: 6px;
}
.node-body table {
border-collapse: collapse;
}
.node-body table td,
.node-body table th {
border: 1px solid var(--mantine-color-dark-4);
padding: 4px 8px;
}
+19
View File
@@ -0,0 +1,19 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/notifications/styles.css";
import "./global.css";
import { theme } from "./theme";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<MantineProvider theme={theme} defaultColorScheme="dark">
<Notifications position="top-right" />
<App />
</MantineProvider>
</StrictMode>,
);
+26
View File
@@ -0,0 +1,26 @@
import { createTheme, type MantineColorsTuple } from "@mantine/core";
// Acento de marca del explorador OSINT: un cian-teal sobrio, distinto del resto
// de apps del ecosistema. Mantine genera sus propias CSS variables a partir de
// este tuple (sin CSS custom, regla frontend_theming.md).
const brand: MantineColorsTuple = [
"#e0fbff",
"#cbf2ff",
"#9ae2ff",
"#64d2ff",
"#3cc5fe",
"#23bdfe",
"#09b9ff",
"#00a3e4",
"#0091cc",
"#007eb4",
];
export const theme = createTheme({
primaryColor: "brand",
colors: { brand },
fontFamily:
"Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
defaultRadius: "md",
headings: { fontWeight: "650" },
});
+44
View File
@@ -0,0 +1,44 @@
// Identidad visual por `tipo` de nodo, compartida entre el grafo (sigma),
// la leyenda, las tablas y las fichas. Los tipos provienen del frontmatter de
// las notas del vault osint; `dangling` no es un tipo sino un flag (nodo
// fantasma de un wikilink sin nota), con su propio estilo atenuado.
export interface TipoStyle {
/** Color hex del nodo / badge. */
color: string;
/** Etiqueta humana para leyenda y filtros. */
label: string;
}
const STYLES: Record<string, TipoStyle> = {
persona: { color: "#3cc5fe", label: "Persona" },
organizacion: { color: "#f59f00", label: "Organización" },
lugar: { color: "#51cf66", label: "Lugar" },
documento: { color: "#cc5de8", label: "Documento" },
caso: { color: "#ff6b6b", label: "Caso" },
dominio: { color: "#20c997", label: "Dominio" },
nota: { color: "#868e96", label: "Nota" },
desconocido: { color: "#adb5bd", label: "Desconocido" },
};
const FALLBACK: TipoStyle = { color: "#adb5bd", label: "Otro" };
/** Color/label de un tipo; cae a un gris neutro si el tipo es desconocido. */
export function tipoStyle(tipo: string): TipoStyle {
return STYLES[tipo] ?? { ...FALLBACK, label: tipo || FALLBACK.label };
}
/** Color atenuado para nodos dangling (wikilink sin nota). */
export const DANGLING_COLOR = "#495057";
/** Color de una arista según su `kind` (wikilink / relacion / documento). */
export function edgeColor(kind: string): string {
switch (kind) {
case "relacion":
return "#5c7cfa";
case "documento":
return "#cc5de8";
default:
return "#343a40";
}
}
+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} />
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
// El backend FastAPI escucha solo en 127.0.0.1:8470 (datos sensibles del vault).
// En dev, /api se proxya al backend para que `pnpm dev` consuma los handlers
// reales sin CORS. La URL del backend puede sobrescribirse con VITE_API_BASE.
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const apiBase = env.VITE_API_BASE || "http://127.0.0.1:8470";
return {
plugins: [react()],
build: { outDir: "dist", emptyOutDir: true },
server: {
host: "127.0.0.1",
port: 5173,
proxy: {
"/api": apiBase,
},
},
};
});