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