merge: quick/nordvpn-db-wails-frontend-notebook — NordVPN, DB multi-engine, Wails, frontend React/TS, Jupyter notebook, lorenz_step
This commit is contained in:
@@ -15,3 +15,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 09 | [go_packages.md](go_packages.md) | Nombre de paquete Go = nombre del directorio |
|
||||
| 10 | [apps_vs_functions.md](apps_vs_functions.md) | Codigo reutilizable en functions/, no reutilizable en apps/ |
|
||||
| 11 | [sources.md](sources.md) | Extraccion de funciones desde repos externos |
|
||||
| 12 | [notebook_collaboration.md](notebook_collaboration.md) | Colaboración en notebooks Jupyter via funciones del registry |
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
## Colaboración en notebooks Jupyter
|
||||
|
||||
### Requisito previo
|
||||
|
||||
El usuario debe tener Jupyter Lab corriendo en modo colaborativo (`--collaborative`) y el notebook abierto en el browser. Sin esto, los cambios no se ven en tiempo real.
|
||||
|
||||
El launcher estándar (`run-jupyter-lab.sh` generado por `init_jupyter_analysis`) ya incluye `--collaborative`.
|
||||
|
||||
### Funciones del registry (dominio `notebook`)
|
||||
|
||||
| Función | ID | Para qué |
|
||||
|---|---|---|
|
||||
| `jupyter_discover` | `jupyter_discover_py_notebook` | Descubrir instancias Jupyter activas, kernels, sesiones, modo colaborativo |
|
||||
| `jupyter_read` | `jupyter_read_py_notebook` | Leer celdas (todas o una), metadata del notebook |
|
||||
| `jupyter_exec` | `jupyter_exec_py_notebook` | Ejecutar: append+execute, execute celda existente, o directo al kernel |
|
||||
| `jupyter_write` | `jupyter_write_py_notebook` | Escribir: append code/markdown, insert, edit, delete celdas |
|
||||
| `jupyter_kernel` | `jupyter_kernel_py_notebook` | CRUD de kernels: list, start, restart, interrupt, shutdown, sessions |
|
||||
|
||||
### Invocación desde cualquier sesión de Claude
|
||||
|
||||
```bash
|
||||
PYTHON="python/.venv/bin/python3"
|
||||
|
||||
# 1. Descubrir qué Jupyter está corriendo
|
||||
$PYTHON python/functions/notebook/jupyter_discover.py --json
|
||||
|
||||
# 2. Leer notebook
|
||||
$PYTHON python/functions/notebook/jupyter_read.py notebooks/01.ipynb --json
|
||||
|
||||
# 3. Añadir celda y ejecutar (el usuario la ve en tiempo real)
|
||||
$PYTHON python/functions/notebook/jupyter_exec.py append notebooks/01.ipynb "df.describe()"
|
||||
|
||||
# 4. Ejecutar celda existente
|
||||
$PYTHON python/functions/notebook/jupyter_exec.py cell notebooks/01.ipynb 3
|
||||
|
||||
# 5. Ejecutar en kernel sin tocar notebook
|
||||
$PYTHON python/functions/notebook/jupyter_exec.py kernel "print(df.shape)"
|
||||
|
||||
# 6. Añadir markdown
|
||||
$PYTHON python/functions/notebook/jupyter_write.py append-markdown notebooks/01.ipynb "## Resumen"
|
||||
|
||||
# 7. Gestionar kernels
|
||||
$PYTHON python/functions/notebook/jupyter_kernel.py list
|
||||
$PYTHON python/functions/notebook/jupyter_kernel.py sessions
|
||||
$PYTHON python/functions/notebook/jupyter_kernel.py shutdown <kernel_id>
|
||||
```
|
||||
|
||||
### Reglas de uso
|
||||
|
||||
- **SIEMPRE** ejecutar `jupyter_discover` primero para confirmar que Jupyter está activo y el notebook abierto.
|
||||
- Las funciones resuelven automáticamente el `kernel_id` de la sesión del notebook y el `username` colaborativo via `/api/sessions` y `/api/me`.
|
||||
- Después de escribir/ejecutar, las funciones mantienen la conexión WebSocket 2 segundos para que Y.js propague los cambios al browser.
|
||||
- **NO usar MCP jupyter** — estas funciones reemplazan al MCP y funcionan desde cualquier directorio sin registrar nada.
|
||||
- El token por defecto es vacío (sin auth). Si el server tiene token, pasarlo con `--token`.
|
||||
- Los paths de notebooks son relativos a la raíz del servidor Jupyter (normalmente `analysis/{tema}/`).
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: install_nordvpn
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "install_nordvpn() -> void"
|
||||
description: "Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2). Configura repositorio oficial, instala paquete y habilita servicio nordvpnd. Idempotente."
|
||||
tags: [vpn, nordvpn, install, infra, wsl2]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/install_nordvpn.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source install_nordvpn.sh
|
||||
install_nordvpn
|
||||
# nordvpn ya instalado: NordVPN Version 3.x.x
|
||||
# — o —
|
||||
# Instalando NordVPN CLI...
|
||||
# NordVPN instalado: NordVPN Version 3.x.x
|
||||
# NOTA: ejecuta 'nordvpn login' para autenticarte
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa el script de instalacion oficial de NordVPN. En WSL2 sin systemd, levanta nordvpnd manualmente. Agrega el usuario al grupo nordvpn para evitar sudo en comandos posteriores. Despues de instalar, se requiere `nordvpn login` para autenticarse.
|
||||
@@ -0,0 +1,41 @@
|
||||
# install_nordvpn
|
||||
# ---------------
|
||||
# Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2).
|
||||
# Configura el repositorio oficial, instala el paquete y habilita el servicio.
|
||||
# Si ya esta instalado, no hace nada.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source install_nordvpn.sh
|
||||
# install_nordvpn
|
||||
|
||||
install_nordvpn() {
|
||||
if command -v nordvpn &>/dev/null; then
|
||||
echo "nordvpn ya instalado: $(nordvpn version 2>/dev/null)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Instalando NordVPN CLI..."
|
||||
|
||||
# Descargar e instalar via script oficial
|
||||
sh <(curl -sSf https://downloads.nordcdn.com/apps/linux/install.sh) 2>&1
|
||||
|
||||
if ! command -v nordvpn &>/dev/null; then
|
||||
echo "install_nordvpn: fallo la instalacion" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Agregar usuario al grupo nordvpn para evitar sudo
|
||||
sudo usermod -aG nordvpn "$USER" 2>/dev/null || true
|
||||
|
||||
# Habilitar servicio (systemd o manual para WSL2)
|
||||
if command -v systemctl &>/dev/null && systemctl is-system-running &>/dev/null 2>&1; then
|
||||
sudo systemctl enable --now nordvpnd 2>/dev/null || true
|
||||
else
|
||||
# WSL2 sin systemd — levantar daemon manualmente
|
||||
sudo nordvpnd &>/dev/null &
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
echo "NordVPN instalado: $(nordvpn version 2>/dev/null)"
|
||||
echo "NOTA: ejecuta 'nordvpn login' para autenticarte"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: nordvpn_connect
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "nordvpn_connect(country?: string, city?: string) -> json"
|
||||
description: "Conecta a NordVPN por pais, ciudad o servidor especifico. Sin argumentos conecta al mejor servidor disponible. Devuelve JSON con resultado."
|
||||
tags: [vpn, nordvpn, connect, infra, network]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/nordvpn_connect.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source nordvpn_connect.sh
|
||||
|
||||
nordvpn_connect
|
||||
# {"ok":true,"server":"us1234.nordvpn.com","country":"auto","city":"auto"}
|
||||
|
||||
nordvpn_connect Spain
|
||||
# {"ok":true,"server":"es42.nordvpn.com","country":"Spain","city":"auto"}
|
||||
|
||||
nordvpn_connect Spain Madrid
|
||||
# {"ok":true,"server":"es15.nordvpn.com","country":"Spain","city":"Madrid"}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Requiere NordVPN CLI instalado y autenticado (`nordvpn login`). La salida JSON facilita composicion con otros scripts y pipelines. Si ya hay una conexion activa, NordVPN reconecta automaticamente al nuevo destino.
|
||||
@@ -0,0 +1,39 @@
|
||||
# nordvpn_connect
|
||||
# ---------------
|
||||
# Conecta a NordVPN. Acepta pais, ciudad o servidor especifico.
|
||||
# Sin argumentos conecta al mejor servidor disponible.
|
||||
# Imprime JSON con el resultado de la conexion.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source nordvpn_connect.sh
|
||||
# nordvpn_connect # mejor servidor
|
||||
# nordvpn_connect Spain # por pais
|
||||
# nordvpn_connect Spain Madrid # por ciudad
|
||||
# nordvpn_connect Spain '#42' # servidor especifico
|
||||
|
||||
nordvpn_connect() {
|
||||
local country="${1:-}"
|
||||
local city="${2:-}"
|
||||
|
||||
if ! command -v nordvpn &>/dev/null; then
|
||||
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local args=()
|
||||
[ -n "$country" ] && args+=("$country")
|
||||
[ -n "$city" ] && args+=("$city")
|
||||
|
||||
local output
|
||||
output=$(nordvpn connect "${args[@]}" 2>&1)
|
||||
local rc=$?
|
||||
|
||||
if [ $rc -eq 0 ] && echo "$output" | grep -qi "connected"; then
|
||||
local server
|
||||
server=$(echo "$output" | grep -oP '(?<=to )\S+' | head -1)
|
||||
echo "{\"ok\":true,\"server\":\"${server}\",\"country\":\"${country:-auto}\",\"city\":\"${city:-auto}\"}"
|
||||
else
|
||||
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: nordvpn_disconnect
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "nordvpn_disconnect() -> json"
|
||||
description: "Desconecta de NordVPN. Idempotente — si no hay conexion activa retorna ok. Devuelve JSON con resultado."
|
||||
tags: [vpn, nordvpn, disconnect, infra, network]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/nordvpn_disconnect.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source nordvpn_disconnect.sh
|
||||
|
||||
nordvpn_disconnect
|
||||
# {"ok":true,"status":"disconnected"}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Idempotente: si no hay conexion activa, retorna ok sin error. Requiere NordVPN CLI instalado.
|
||||
@@ -0,0 +1,26 @@
|
||||
# nordvpn_disconnect
|
||||
# ------------------
|
||||
# Desconecta de NordVPN. Idempotente — si no hay conexion activa, retorna ok.
|
||||
# Imprime JSON con el resultado.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source nordvpn_disconnect.sh
|
||||
# nordvpn_disconnect
|
||||
|
||||
nordvpn_disconnect() {
|
||||
if ! command -v nordvpn &>/dev/null; then
|
||||
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local output
|
||||
output=$(nordvpn disconnect 2>&1)
|
||||
local rc=$?
|
||||
|
||||
if [ $rc -eq 0 ] || echo "$output" | grep -qi "not connected\|disconnected"; then
|
||||
echo '{"ok":true,"status":"disconnected"}'
|
||||
else
|
||||
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: nordvpn_get_ip
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "nordvpn_get_ip() -> json"
|
||||
description: "Obtiene IP publica actual con fallback entre multiples servicios. Indica si la conexion VPN esta activa y el servidor usado."
|
||||
tags: [vpn, nordvpn, ip, infra, network, verification]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/nordvpn_get_ip.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source nordvpn_get_ip.sh
|
||||
|
||||
# Con VPN activa:
|
||||
nordvpn_get_ip
|
||||
# {"ok":true,"ip":"185.x.x.x","vpn_connected":true,"vpn_server":"es42.nordvpn.com","source":"https://api.ipify.org"}
|
||||
|
||||
# Sin VPN:
|
||||
nordvpn_get_ip
|
||||
# {"ok":true,"ip":"88.x.x.x","vpn_connected":false,"vpn_server":"","source":"https://api.ipify.org"}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa ipify.org como servicio primario con fallback a ifconfig.me e icanhazip.com. Timeout de 5 segundos por servicio. Util para verificar que el tunel VPN esta activo antes de ejecutar operaciones sensibles a la IP.
|
||||
@@ -0,0 +1,42 @@
|
||||
# nordvpn_get_ip
|
||||
# --------------
|
||||
# Obtiene la IP publica actual para verificar que el tunel VPN funciona.
|
||||
# Usa multiples servicios como fallback.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source nordvpn_get_ip.sh
|
||||
# nordvpn_get_ip
|
||||
|
||||
nordvpn_get_ip() {
|
||||
local ip=""
|
||||
local source=""
|
||||
|
||||
# Intentar multiples servicios
|
||||
for svc in "https://api.ipify.org" "https://ifconfig.me" "https://icanhazip.com"; do
|
||||
ip=$(curl -s --max-time 5 "$svc" 2>/dev/null)
|
||||
if echo "$ip" | grep -qP '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'; then
|
||||
source="$svc"
|
||||
break
|
||||
fi
|
||||
ip=""
|
||||
done
|
||||
|
||||
if [ -z "$ip" ]; then
|
||||
echo '{"ok":false,"error":"no se pudo obtener IP publica"}' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Si nordvpn esta disponible, incluir info de conexion
|
||||
local connected="false"
|
||||
local vpn_server=""
|
||||
if command -v nordvpn &>/dev/null; then
|
||||
local status_output
|
||||
status_output=$(nordvpn status 2>/dev/null)
|
||||
if echo "$status_output" | grep -qi "connected"; then
|
||||
connected="true"
|
||||
vpn_server=$(echo "$status_output" | grep -iP "hostname|server" | head -1 | sed 's/.*: *//')
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "{\"ok\":true,\"ip\":\"$ip\",\"vpn_connected\":$connected,\"vpn_server\":\"$vpn_server\",\"source\":\"$source\"}"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: nordvpn_list_cities
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "nordvpn_list_cities(country: string) -> json"
|
||||
description: "Lista ciudades disponibles de un pais en NordVPN como array JSON ordenado."
|
||||
tags: [vpn, nordvpn, cities, infra, network]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/nordvpn_list_cities.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source nordvpn_list_cities.sh
|
||||
|
||||
nordvpn_list_cities Spain
|
||||
# {"ok":true,"country":"Spain","count":2,"cities":["Barcelona","Madrid"]}
|
||||
|
||||
nordvpn_list_cities "United_States"
|
||||
# {"ok":true,"country":"United_States","count":15,"cities":["Atlanta","Buffalo",...]}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
El nombre de pais debe coincidir con lo que devuelve `nordvpn countries`. Usa underscores para paises compuestos (ej: United_States). Las ciudades se devuelven con espacios.
|
||||
@@ -0,0 +1,38 @@
|
||||
# nordvpn_list_cities
|
||||
# -------------------
|
||||
# Lista las ciudades disponibles de un pais en NordVPN como array JSON.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source nordvpn_list_cities.sh
|
||||
# nordvpn_list_cities Spain
|
||||
|
||||
nordvpn_list_cities() {
|
||||
local country="${1:?nordvpn_list_cities: se requiere pais como argumento}"
|
||||
|
||||
if ! command -v nordvpn &>/dev/null; then
|
||||
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local output
|
||||
output=$(nordvpn cities "$country" 2>&1)
|
||||
local rc=$?
|
||||
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$output" | python3 -c '
|
||||
import sys, json, re
|
||||
|
||||
country = "'"$country"'"
|
||||
text = sys.stdin.read()
|
||||
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
|
||||
text = re.sub(r"[\t\r]", " ", text)
|
||||
cities = [c.strip().replace("_", " ") for c in re.split(r"[,\n]+", text) if c.strip() and c.strip() != "-"]
|
||||
cities = [c for c in cities if len(c) > 1]
|
||||
cities.sort()
|
||||
print(json.dumps({"ok": True, "country": country, "count": len(cities), "cities": cities}))
|
||||
'
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: nordvpn_list_countries
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "nordvpn_list_countries() -> json"
|
||||
description: "Lista paises disponibles en NordVPN como array JSON ordenado alfabeticamente."
|
||||
tags: [vpn, nordvpn, countries, infra, network]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/nordvpn_list_countries.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source nordvpn_list_countries.sh
|
||||
|
||||
nordvpn_list_countries
|
||||
# {"ok":true,"count":60,"countries":["Albania","Argentina","Australia",...,"United States","Vietnam"]}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Parsea la salida de `nordvpn countries` eliminando codigos ANSI y normalizando separadores. Los nombres de paises se devuelven con espacios en vez de underscores.
|
||||
@@ -0,0 +1,36 @@
|
||||
# nordvpn_list_countries
|
||||
# ----------------------
|
||||
# Lista los paises disponibles en NordVPN como array JSON.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source nordvpn_list_countries.sh
|
||||
# nordvpn_list_countries
|
||||
|
||||
nordvpn_list_countries() {
|
||||
if ! command -v nordvpn &>/dev/null; then
|
||||
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local output
|
||||
output=$(nordvpn countries 2>&1)
|
||||
local rc=$?
|
||||
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$output" | python3 -c '
|
||||
import sys, json, re
|
||||
|
||||
text = sys.stdin.read()
|
||||
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
|
||||
text = re.sub(r"[\t\r]", " ", text)
|
||||
# Split by comma, whitespace, or newline and clean
|
||||
countries = [c.strip().replace("_", " ") for c in re.split(r"[,\n]+", text) if c.strip() and c.strip() != "-"]
|
||||
countries = [c for c in countries if len(c) > 1]
|
||||
countries.sort()
|
||||
print(json.dumps({"ok": True, "count": len(countries), "countries": countries}))
|
||||
'
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: nordvpn_set_protocol
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "nordvpn_set_protocol(protocol: string) -> json"
|
||||
description: "Cambia el protocolo de NordVPN entre NordLynx (WireGuard) y OpenVPN. NordLynx recomendado por velocidad."
|
||||
tags: [vpn, nordvpn, protocol, nordlynx, wireguard, openvpn, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/nordvpn_set_protocol.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source nordvpn_set_protocol.sh
|
||||
|
||||
nordvpn_set_protocol NordLynx
|
||||
# {"ok":true,"protocol":"NordLynx"}
|
||||
|
||||
nordvpn_set_protocol OpenVPN
|
||||
# {"ok":true,"protocol":"OpenVPN"}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
NordLynx es WireGuard wrapeado por NordVPN — mas rapido y moderno. OpenVPN es mas compatible con redes restrictivas. El cambio de protocolo requiere reconectar si hay una conexion activa.
|
||||
@@ -0,0 +1,38 @@
|
||||
# nordvpn_set_protocol
|
||||
# --------------------
|
||||
# Cambia el protocolo de NordVPN (NordLynx o OpenVPN).
|
||||
# NordLynx = WireGuard (recomendado por velocidad).
|
||||
#
|
||||
# USO (sourced):
|
||||
# source nordvpn_set_protocol.sh
|
||||
# nordvpn_set_protocol NordLynx
|
||||
# nordvpn_set_protocol OpenVPN
|
||||
|
||||
nordvpn_set_protocol() {
|
||||
local protocol="${1:?nordvpn_set_protocol: se requiere protocolo (NordLynx|OpenVPN)}"
|
||||
|
||||
if ! command -v nordvpn &>/dev/null; then
|
||||
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$protocol" in
|
||||
NordLynx|nordlynx|NORDLYNX) protocol="NordLynx" ;;
|
||||
OpenVPN|openvpn|OPENVPN) protocol="OpenVPN" ;;
|
||||
*)
|
||||
echo "{\"ok\":false,\"error\":\"protocolo invalido: $protocol (usar NordLynx o OpenVPN)\"}"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local output
|
||||
output=$(nordvpn set protocol "$protocol" 2>&1)
|
||||
local rc=$?
|
||||
|
||||
if [ $rc -eq 0 ] || echo "$output" | grep -qi "already set\|successfully"; then
|
||||
echo "{\"ok\":true,\"protocol\":\"$protocol\"}"
|
||||
else
|
||||
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: nordvpn_status
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "nordvpn_status() -> json"
|
||||
description: "Obtiene estado actual de NordVPN como JSON estructurado. Incluye servidor, IP, pais, protocolo y estado de conexion."
|
||||
tags: [vpn, nordvpn, status, infra, network]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/nordvpn_status.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source nordvpn_status.sh
|
||||
|
||||
nordvpn_status
|
||||
# {"ok":true,"connected":true,"status":"Connected","hostname":"es42.nordvpn.com","ip":"185.x.x.x","country":"Spain","city":"Madrid","current_technology":"NordLynx","current_protocol":"nordlynx","transfer":"1.2 MiB received, 500 KiB sent","uptime":"5 minutes 32 seconds"}
|
||||
|
||||
# Desconectado:
|
||||
# {"ok":true,"connected":false,"status":"Disconnected"}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Parsea la salida clave-valor de `nordvpn status` eliminando codigos ANSI. Los campos disponibles dependen del estado de conexion — cuando esta desconectado solo devuelve status y connected.
|
||||
@@ -0,0 +1,43 @@
|
||||
# nordvpn_status
|
||||
# --------------
|
||||
# Obtiene el estado actual de NordVPN como JSON estructurado.
|
||||
# Parsea la salida clave-valor de `nordvpn status` a campos JSON.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source nordvpn_status.sh
|
||||
# nordvpn_status
|
||||
|
||||
nordvpn_status() {
|
||||
if ! command -v nordvpn &>/dev/null; then
|
||||
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local output
|
||||
output=$(nordvpn status 2>&1)
|
||||
local rc=$?
|
||||
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parsear output clave: valor a JSON con python3
|
||||
echo "$output" | python3 -c '
|
||||
import sys, json, re
|
||||
|
||||
lines = sys.stdin.read().strip().split("\n")
|
||||
data = {"ok": True}
|
||||
for line in lines:
|
||||
line = re.sub(r"\x1b\[[0-9;]*m", "", line).strip()
|
||||
line = line.lstrip("- ")
|
||||
if ":" in line:
|
||||
key, _, val = line.partition(":")
|
||||
key = key.strip().lower().replace(" ", "_")
|
||||
val = val.strip()
|
||||
if key == "status":
|
||||
data["connected"] = val.lower() == "connected"
|
||||
data[key] = val
|
||||
print(json.dumps(data))
|
||||
'
|
||||
}
|
||||
@@ -56,7 +56,6 @@ jupyter lab \
|
||||
--ServerApp.disable_check_xsrf=True \
|
||||
--ServerApp.allow_origin='*' \
|
||||
--ServerApp.root_dir="$(pwd)" \
|
||||
--YDocExtension.ystore_class='ypy_websocket.ystore.TempFileYStore' \
|
||||
--collaborative
|
||||
LAUNCHER
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# write_mcp_jupyter_config
|
||||
# -------------------------
|
||||
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
|
||||
# Usa el python del venv local con -m jupyter_mcp_server.
|
||||
# Usa el python del venv local con -m jupyter_mcp_server.server.
|
||||
# Configura via env vars (SERVER_URL, TOKEN) — no CLI args.
|
||||
# Hace merge si ya existe .mcp.json (requiere jq).
|
||||
#
|
||||
# USO (sourced):
|
||||
@@ -33,11 +34,11 @@ write_mcp_jupyter_config() {
|
||||
"mcpServers": {
|
||||
"jupyter": {
|
||||
"command": "${python_bin}",
|
||||
"args": [
|
||||
"-m", "jupyter_mcp_server",
|
||||
"--runtime-url", "http://localhost:${port}",
|
||||
"--start-new-runtime", "false"
|
||||
]
|
||||
"args": ["-m", "jupyter_mcp_server.server"],
|
||||
"env": {
|
||||
"SERVER_URL": "http://localhost:${port}",
|
||||
"TOKEN": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: chart_colors
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "getChartColor(index: number): string"
|
||||
description: "Paleta de colores para gráficos basada en CSS variables del tema activo. Colores accesibles por índice cíclico."
|
||||
tags: [chart, color, theme, palette, visualization]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/chart_colors.ts"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/charts/chart-base.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
getChartColor(0) // 'hsl(var(--chart-1, 220 70% 50%))'
|
||||
getChartColor(7) // 'hsl(var(--chart-3, 30 80% 55%))' — cicla sobre 5 colores
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa CSS variables del tema con fallback hardcodeado. Los colores cambian automáticamente con el tema activo. También exporta `chartColors` (array) para uso directo.
|
||||
@@ -0,0 +1,11 @@
|
||||
export const chartColors = [
|
||||
'hsl(var(--chart-1, 220 70% 50%))',
|
||||
'hsl(var(--chart-2, 160 60% 45%))',
|
||||
'hsl(var(--chart-3, 30 80% 55%))',
|
||||
'hsl(var(--chart-4, 280 65% 60%))',
|
||||
'hsl(var(--chart-5, 340 75% 55%))',
|
||||
]
|
||||
|
||||
export function getChartColor(index: number): string {
|
||||
return chartColors[index % chartColors.length]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: cn
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "cn(...inputs: ClassValue[]): string"
|
||||
description: "Combina clases CSS con clsx y resuelve conflictos Tailwind con tailwind-merge. Utilidad fundamental para composición de estilos."
|
||||
tags: [css, tailwind, classname, merge, utility]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [clsx, tailwind-merge]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/cn.ts"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/lib/utils.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
cn("px-4 py-2", "px-6") // "px-6 py-2" (tailwind-merge resuelve conflicto)
|
||||
cn("text-red-500", false && "hidden") // "text-red-500" (clsx filtra falsy)
|
||||
cn("rounded-lg", className) // composición con className externo
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Base de todo el sistema de estilos. Todos los componentes la usan para componer className.
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: format_compact
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "formatCompact(n: number, decimals?: number): string"
|
||||
description: "Familia de funciones de formato compacto: números (K/M/B), frecuencia (Hz/KHz/MHz), bytes (KB/MB/GB), duración (ms/s/min/h)."
|
||||
tags: [format, number, compact, utility, display]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/format_compact.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
formatCompact(1234) // '1.2K'
|
||||
formatCompact(1500000) // '1.5M'
|
||||
formatHz(44100) // '44.1 KHz'
|
||||
formatBytes(1073741824) // '1.0 GB'
|
||||
formatDuration(3500) // '3.5s'
|
||||
formatDuration(0.5) // '500µs'
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Todas son funciones puras sin dependencias. Útiles en dashboards, KPI cards, tablas y tooltips.
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Formatea un número en formato compacto (1K, 1.2M, etc.)
|
||||
* Soporta sufijos personalizados.
|
||||
*/
|
||||
export function formatCompact(n: number, decimals: number = 1): string {
|
||||
if (Math.abs(n) >= 1_000_000_000) return (n / 1_000_000_000).toFixed(decimals) + 'B'
|
||||
if (Math.abs(n) >= 1_000_000) return (n / 1_000_000).toFixed(decimals) + 'M'
|
||||
if (Math.abs(n) >= 1_000) return (n / 1_000).toFixed(decimals) + 'K'
|
||||
return n.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea frecuencia en Hz/KHz/MHz/GHz.
|
||||
*/
|
||||
export function formatHz(hz: number, decimals: number = 1): string {
|
||||
if (hz >= 1_000_000_000) return (hz / 1_000_000_000).toFixed(decimals) + ' GHz'
|
||||
if (hz >= 1_000_000) return (hz / 1_000_000).toFixed(decimals) + ' MHz'
|
||||
if (hz >= 1_000) return (hz / 1_000).toFixed(decimals) + ' KHz'
|
||||
return hz + ' Hz'
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea bytes en KB/MB/GB/TB.
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals: number = 1): string {
|
||||
if (bytes >= 1_099_511_627_776) return (bytes / 1_099_511_627_776).toFixed(decimals) + ' TB'
|
||||
if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(decimals) + ' GB'
|
||||
if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(decimals) + ' MB'
|
||||
if (bytes >= 1_024) return (bytes / 1_024).toFixed(decimals) + ' KB'
|
||||
return bytes + ' B'
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea duración en ms/s/min/h.
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms >= 3_600_000) return (ms / 3_600_000).toFixed(1) + 'h'
|
||||
if (ms >= 60_000) return (ms / 60_000).toFixed(1) + 'min'
|
||||
if (ms >= 1_000) return (ms / 1_000).toFixed(1) + 's'
|
||||
if (ms >= 1) return ms.toFixed(0) + 'ms'
|
||||
return (ms * 1000).toFixed(0) + 'µs'
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: get_series_color
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "getSeriesColor(index: number, color?: string): string"
|
||||
description: "Devuelve color para una serie de gráfico por índice cíclico, o el color explícito si se proporciona."
|
||||
tags: [chart, color, series, visualization]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/get_series_color.ts"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/charts/chart-base.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
getSeriesColor(0) // '#3b82f6'
|
||||
getSeriesColor(5) // '#3b82f6' (cicla sobre 5 colores)
|
||||
getSeriesColor(0, '#ff0000') // '#ff0000' (usa el explícito)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Paleta fija de 5 colores: azul, verde, ámbar, violeta, rosa. También exporta `defaultColors` para uso directo.
|
||||
@@ -0,0 +1,7 @@
|
||||
const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']
|
||||
|
||||
export function getSeriesColor(index: number, color?: string): string {
|
||||
return color || defaultColors[index % defaultColors.length]
|
||||
}
|
||||
|
||||
export { defaultColors }
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: theme_config_to_colors
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "themeConfigToColors(config: ThemeConfig): ThemeColors"
|
||||
description: "Convierte un ThemeConfig completo a ThemeColors plano para inyectar como CSS variables. Mapea tokens semánticos a variables CSS."
|
||||
tags: [theme, colors, css-variables, conversion]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/theme_config_to_colors.ts"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/themes/types.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
const colors = themeConfigToColors(darkThemeConfig)
|
||||
// { background: '...', foreground: '...', primary: '...', ... }
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Puente entre el sistema de temas estructurado (ThemeConfig) y el sistema plano de CSS variables que consumen los componentes.
|
||||
|
||||
Depende de los tipos ThemeConfig y ThemeColors definidos en `frontend/types/ui/theme_config.ts`. El tipo aún no está indexado en la BD (pendiente añadir theme_config.md para que fn index lo registre).
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ThemeConfig, ThemeColors } from "../../types/ui/theme_config"
|
||||
|
||||
export function themeConfigToColors(config: ThemeConfig): ThemeColors {
|
||||
const { colors } = config
|
||||
|
||||
return {
|
||||
background: colors.background.default,
|
||||
foreground: colors.foreground.default,
|
||||
card: colors.surface.raised,
|
||||
cardForeground: colors.foreground.default,
|
||||
popover: colors.surface.overlay,
|
||||
popoverForeground: colors.foreground.default,
|
||||
primary: colors.brand.primary,
|
||||
primaryForeground: colors.brand.primaryForeground,
|
||||
secondary: colors.brand.secondary,
|
||||
secondaryForeground: colors.brand.secondaryForeground,
|
||||
muted: colors.background.muted,
|
||||
mutedForeground: colors.foreground.muted,
|
||||
accent: colors.brand.accent,
|
||||
accentForeground: colors.brand.accentForeground,
|
||||
destructive: colors.status.error,
|
||||
destructiveForeground: colors.status.errorForeground,
|
||||
success: colors.status.success,
|
||||
successForeground: colors.status.successForeground,
|
||||
warning: colors.status.warning,
|
||||
warningForeground: colors.status.warningForeground,
|
||||
info: colors.status.info,
|
||||
infoForeground: colors.status.infoForeground,
|
||||
surface: colors.surface.raised,
|
||||
surfaceHover: colors.background.subtle,
|
||||
overlay: colors.surface.overlay,
|
||||
border: colors.border.default,
|
||||
input: colors.border.default,
|
||||
ring: colors.ring,
|
||||
chart1: colors.chart[1],
|
||||
chart2: colors.chart[2],
|
||||
chart3: colors.chart[3],
|
||||
chart4: colors.chart[4],
|
||||
chart5: colors.chart[5],
|
||||
sidebar: colors.sidebar.background,
|
||||
sidebarForeground: colors.sidebar.foreground,
|
||||
sidebarPrimary: colors.brand.primary,
|
||||
sidebarPrimaryForeground: colors.brand.primaryForeground,
|
||||
sidebarAccent: colors.sidebar.accent,
|
||||
sidebarAccentForeground: colors.sidebar.accentForeground,
|
||||
sidebarBorder: colors.sidebar.border,
|
||||
sidebarRing: colors.sidebar.ring,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: wails_cache
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "class WailsCache { get<T>(key: string[]): T | null; set<T>(key: string[], data: T): void; invalidate(key: string[]): void; subscribe(key: string[], cb: () => void): () => void }"
|
||||
description: "Cache reactivo para IPC Wails con invalidación por prefijo, suscripción a cambios y tracking de staleness. Singleton global."
|
||||
tags: [wails, cache, ipc, reactive, state]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/wails_cache.ts"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/lib/wails/cache.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
import { wailsCache } from './wails_cache'
|
||||
|
||||
wailsCache.set(['users', '123'], userData)
|
||||
const user = wailsCache.get<User>(['users', '123'])
|
||||
wailsCache.invalidate(['users']) // invalida users:*
|
||||
const unsub = wailsCache.subscribe(['users'], () => console.log('changed'))
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Key como string[] permite invalidación jerárquica: `invalidate(['users'])` invalida `users`, `users:123`, `users:456`, etc.
|
||||
@@ -0,0 +1,99 @@
|
||||
interface CacheEntry {
|
||||
data: unknown
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export class WailsCache {
|
||||
private cache = new Map<string, CacheEntry>()
|
||||
private subscribers = new Map<string, Set<() => void>>()
|
||||
|
||||
/** Generar key string desde array */
|
||||
private getKey(queryKey: string[]): string {
|
||||
return queryKey.join(':')
|
||||
}
|
||||
|
||||
/** Obtener dato del cache */
|
||||
get<T>(queryKey: string[]): T | null {
|
||||
const entry = this.cache.get(this.getKey(queryKey))
|
||||
return (entry?.data as T) ?? null
|
||||
}
|
||||
|
||||
/** Guardar dato en cache */
|
||||
set<T>(queryKey: string[], data: T): void {
|
||||
const key = this.getKey(queryKey)
|
||||
this.cache.set(key, { data, timestamp: new Date() })
|
||||
this.notifySubscribers(key)
|
||||
}
|
||||
|
||||
/** Verificar si existe en cache */
|
||||
has(queryKey: string[]): boolean {
|
||||
return this.cache.has(this.getKey(queryKey))
|
||||
}
|
||||
|
||||
/** Obtener timestamp de última actualización */
|
||||
getTimestamp(queryKey: string[]): Date | null {
|
||||
const entry = this.cache.get(this.getKey(queryKey))
|
||||
return entry?.timestamp ?? null
|
||||
}
|
||||
|
||||
/** Verificar si los datos están stale */
|
||||
isStale(queryKey: string[], staleTime: number): boolean {
|
||||
const entry = this.cache.get(this.getKey(queryKey))
|
||||
if (!entry) return true
|
||||
return Date.now() - entry.timestamp.getTime() > staleTime
|
||||
}
|
||||
|
||||
/** Invalidar cache (esta key y todas las que empiezan igual) */
|
||||
invalidate(queryKey: string[]): void {
|
||||
const prefix = this.getKey(queryKey)
|
||||
const keysToDelete: string[] = []
|
||||
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key === prefix || key.startsWith(prefix + ':')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
keysToDelete.forEach((key) => {
|
||||
this.cache.delete(key)
|
||||
this.notifySubscribers(key)
|
||||
})
|
||||
}
|
||||
|
||||
/** Limpiar todo el cache */
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
this.subscribers.forEach((_, key) => this.notifySubscribers(key))
|
||||
}
|
||||
|
||||
/** Subscribirse a cambios en una key */
|
||||
subscribe(queryKey: string[], callback: () => void): () => void {
|
||||
const key = this.getKey(queryKey)
|
||||
if (!this.subscribers.has(key)) {
|
||||
this.subscribers.set(key, new Set())
|
||||
}
|
||||
this.subscribers.get(key)!.add(callback)
|
||||
|
||||
return () => {
|
||||
this.subscribers.get(key)?.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/** Notificar a subscribers */
|
||||
private notifySubscribers(key: string): void {
|
||||
this.subscribers.get(key)?.forEach((callback) => callback())
|
||||
}
|
||||
|
||||
/** Obtener tamaño del cache */
|
||||
get size(): number {
|
||||
return this.cache.size
|
||||
}
|
||||
|
||||
/** Obtener todas las keys */
|
||||
keys(): string[] {
|
||||
return Array.from(this.cache.keys())
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton global
|
||||
export const wailsCache = new WailsCache()
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: alert
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Alert(props: { variant?: 'default' | 'destructive' }): JSX.Element"
|
||||
description: "Alerta accesible con variantes default y destructive. Sistema de slots para título, descripción, icono y acción."
|
||||
tags: [alert, feedback, component, ui, notification]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react, class-variance-authority]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/alert.tsx"
|
||||
props:
|
||||
- name: variant
|
||||
type: "'default' | 'destructive'"
|
||||
required: false
|
||||
description: "Variante visual"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default, destructive]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/alert.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Something went wrong.</AlertDescription>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Exporta 4 subcomponentes composables via data-slot: Alert, AlertTitle, AlertDescription, AlertAction.
|
||||
El icono SVG se posiciona automáticamente en grid cuando es hijo directo de Alert.
|
||||
AlertAction se posiciona absolute top-right para acciones secundarias (ej: botón cerrar).
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive: "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="alert-title" className={cn("font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", className)} {...props} />
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="alert-description" className={cn("text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4", className)} {...props} />
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="alert-action" className={cn("absolute top-2 right-2", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants }
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: analytics_page
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "analyticsPage(props: AnalyticsPageProps): ReactElement"
|
||||
description: "Genera un dashboard de analytics completo con header, fila de KPIs con deltas y grid de charts configurables."
|
||||
tags: [analytics, dashboard, kpi, charts, factory, composition, ui]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/analytics_page.tsx"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: ""
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
analyticsPage({
|
||||
title: 'Sales Analytics',
|
||||
metrics: [
|
||||
{ label: 'Revenue', value: '$124,500', delta: { value: 12.5, isPositive: true } },
|
||||
{ label: 'Orders', value: '1,234', delta: { value: -3.2, isPositive: false } },
|
||||
{ label: 'Avg Order', value: '$101', delta: { value: 0, isPositive: true } },
|
||||
{ label: 'Customers', value: '892' },
|
||||
],
|
||||
charts: [
|
||||
{ id: 'revenue', title: 'Revenue Over Time', type: 'area', span: 2, content: <AreaChart data={revenueData} xKey="month" yKey="revenue" /> },
|
||||
{ id: 'orders', title: 'Orders by Category', type: 'bar', content: <BarChart data={orderData} xKey="category" yKey="count" /> },
|
||||
{ id: 'trends', title: 'Customer Trends', type: 'line', content: <LineChart data={trendData} xKey="week" yKey="customers" /> },
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Layout inteligente: los KPIs se ajustan automáticamente a 2/3/4 columnas según cantidad. Los charts soportan span para ancho completo.
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../core/cn'
|
||||
|
||||
interface MetricConfig {
|
||||
label: string
|
||||
value: string | number
|
||||
delta?: { value: number; isPositive: boolean }
|
||||
sparklineData?: number[]
|
||||
}
|
||||
|
||||
interface ChartConfig {
|
||||
id: string
|
||||
title: string
|
||||
type: 'line' | 'bar' | 'area'
|
||||
span?: 1 | 2
|
||||
height?: number
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
interface AnalyticsPageProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
dateRange?: React.ReactNode
|
||||
metrics: MetricConfig[]
|
||||
charts: ChartConfig[]
|
||||
actions?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function analyticsPage({
|
||||
title,
|
||||
subtitle,
|
||||
dateRange,
|
||||
metrics,
|
||||
charts,
|
||||
actions,
|
||||
className,
|
||||
}: AnalyticsPageProps): React.ReactElement {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{dateRange}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Row */}
|
||||
<div className={cn(
|
||||
'grid gap-4',
|
||||
metrics.length <= 2 ? 'grid-cols-1 md:grid-cols-2' :
|
||||
metrics.length <= 3 ? 'grid-cols-1 md:grid-cols-3' :
|
||||
'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'
|
||||
)}>
|
||||
{metrics.map((metric, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">{metric.label}</p>
|
||||
<div className="mt-2 flex items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-3xl font-bold tracking-tight">{metric.value}</p>
|
||||
{metric.delta && (
|
||||
<div className={cn(
|
||||
'flex items-center gap-1 text-sm font-medium',
|
||||
metric.delta.value === 0 ? 'text-muted-foreground' :
|
||||
metric.delta.isPositive ? 'text-green-600 dark:text-green-500' :
|
||||
'text-red-600 dark:text-red-500'
|
||||
)}>
|
||||
<span>{metric.delta.value > 0 ? '+' : ''}{metric.delta.value}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{charts.map((chart) => (
|
||||
<div
|
||||
key={chart.id}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card p-4 text-card-foreground shadow-sm',
|
||||
chart.span === 2 && 'lg:col-span-2'
|
||||
)}
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-medium text-muted-foreground">{chart.title}</h3>
|
||||
{chart.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { AnalyticsPageProps, MetricConfig, ChartConfig }
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: apply_theme
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "applyTheme(theme: Theme): void"
|
||||
description: "Inyecta un tema como CSS variables en document.documentElement. Maneja clase dark automáticamente. Mapea 40 tokens semánticos."
|
||||
tags: [theme, css-variables, apply, runtime, ui]
|
||||
uses_functions: []
|
||||
uses_types: [ThemeConfig_typescript_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/apply_theme.tsx"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/hooks/use-theme.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
import { applyTheme } from './apply_theme'
|
||||
|
||||
applyTheme({
|
||||
name: 'dark',
|
||||
label: 'Oscuro',
|
||||
colors: themeConfigToColors(darkThemeConfig)
|
||||
})
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Función impura (modifica el DOM). Mapea cada key de ThemeColors a una CSS variable. Temas oscuros (dark, midnight, sunset) añaden clase `dark` al root.
|
||||
@@ -0,0 +1,111 @@
|
||||
interface ThemeColors {
|
||||
background: string
|
||||
foreground: string
|
||||
card: string
|
||||
cardForeground: string
|
||||
popover: string
|
||||
popoverForeground: string
|
||||
primary: string
|
||||
primaryForeground: string
|
||||
secondary: string
|
||||
secondaryForeground: string
|
||||
muted: string
|
||||
mutedForeground: string
|
||||
accent: string
|
||||
accentForeground: string
|
||||
destructive: string
|
||||
destructiveForeground: string
|
||||
success: string
|
||||
successForeground: string
|
||||
warning: string
|
||||
warningForeground: string
|
||||
info: string
|
||||
infoForeground: string
|
||||
surface: string
|
||||
surfaceHover: string
|
||||
overlay: string
|
||||
border: string
|
||||
input: string
|
||||
ring: string
|
||||
chart1: string
|
||||
chart2: string
|
||||
chart3: string
|
||||
chart4: string
|
||||
chart5: string
|
||||
sidebar: string
|
||||
sidebarForeground: string
|
||||
sidebarPrimary: string
|
||||
sidebarPrimaryForeground: string
|
||||
sidebarAccent: string
|
||||
sidebarAccentForeground: string
|
||||
sidebarBorder: string
|
||||
sidebarRing: string
|
||||
}
|
||||
|
||||
interface Theme {
|
||||
name: string
|
||||
label: string
|
||||
colors: ThemeColors
|
||||
}
|
||||
|
||||
const cssVarMap: Record<keyof ThemeColors, string> = {
|
||||
background: '--background',
|
||||
foreground: '--foreground',
|
||||
card: '--card',
|
||||
cardForeground: '--card-foreground',
|
||||
popover: '--popover',
|
||||
popoverForeground: '--popover-foreground',
|
||||
primary: '--primary',
|
||||
primaryForeground: '--primary-foreground',
|
||||
secondary: '--secondary',
|
||||
secondaryForeground: '--secondary-foreground',
|
||||
muted: '--muted',
|
||||
mutedForeground: '--muted-foreground',
|
||||
accent: '--accent',
|
||||
accentForeground: '--accent-foreground',
|
||||
destructive: '--destructive',
|
||||
destructiveForeground: '--destructive-foreground',
|
||||
success: '--success',
|
||||
successForeground: '--success-foreground',
|
||||
warning: '--warning',
|
||||
warningForeground: '--warning-foreground',
|
||||
info: '--info',
|
||||
infoForeground: '--info-foreground',
|
||||
surface: '--surface',
|
||||
surfaceHover: '--surface-hover',
|
||||
overlay: '--overlay',
|
||||
border: '--border',
|
||||
input: '--input',
|
||||
ring: '--ring',
|
||||
chart1: '--chart-1',
|
||||
chart2: '--chart-2',
|
||||
chart3: '--chart-3',
|
||||
chart4: '--chart-4',
|
||||
chart5: '--chart-5',
|
||||
sidebar: '--sidebar',
|
||||
sidebarForeground: '--sidebar-foreground',
|
||||
sidebarPrimary: '--sidebar-primary',
|
||||
sidebarPrimaryForeground: '--sidebar-primary-foreground',
|
||||
sidebarAccent: '--sidebar-accent',
|
||||
sidebarAccentForeground: '--sidebar-accent-foreground',
|
||||
sidebarBorder: '--sidebar-border',
|
||||
sidebarRing: '--sidebar-ring',
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme): void {
|
||||
const root = document.documentElement
|
||||
const colors = theme.colors
|
||||
|
||||
Object.entries(cssVarMap).forEach(([key, cssVar]) => {
|
||||
const value = colors[key as keyof ThemeColors]
|
||||
root.style.setProperty(cssVar, value)
|
||||
})
|
||||
|
||||
if (theme.name === 'dark' || theme.name === 'midnight' || theme.name === 'sunset') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
export type { Theme, ThemeColors }
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: area_chart
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "AreaChart(props: AreaChartProps): JSX.Element"
|
||||
description: "Gráfico de área Recharts con gradientes automáticos, multi-series, stacking y tooltips temáticos."
|
||||
tags: [chart, area, visualization, recharts, gradient, component, ui]
|
||||
uses_functions: [cn_typescript_core, chart_container_typescript_ui, get_series_color_typescript_core]
|
||||
uses_types: [ChartSeries_typescript_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [recharts]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/area_chart.tsx"
|
||||
props:
|
||||
- name: data
|
||||
type: "Record<string, unknown>[]"
|
||||
required: true
|
||||
description: "Array de datos"
|
||||
- name: xKey
|
||||
type: "string"
|
||||
required: true
|
||||
description: "Key del eje X"
|
||||
- name: stacked
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Apilar áreas"
|
||||
- name: gradient
|
||||
type: "GradientConfig | boolean"
|
||||
required: false
|
||||
description: "Gradiente (true por defecto)"
|
||||
- name: series
|
||||
type: "Series[]"
|
||||
required: false
|
||||
description: "Series de datos para multi-series"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default, stacked]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/charts/area-chart.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<AreaChart data={data} xKey="date" yKey="revenue" gradient />
|
||||
<AreaChart data={data} xKey="date" series={series} stacked showLegend />
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
AreaChart as RechartsAreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
} from 'recharts'
|
||||
import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container'
|
||||
|
||||
interface GradientConfig { from: string; to: string }
|
||||
|
||||
interface AreaChartProps {
|
||||
data: Record<string, unknown>[]
|
||||
xKey: string
|
||||
yKey?: string
|
||||
series?: Series[]
|
||||
stacked?: boolean
|
||||
gradient?: GradientConfig | boolean
|
||||
showGrid?: boolean
|
||||
showLegend?: boolean
|
||||
height?: number | string
|
||||
className?: string
|
||||
xAxisFormatter?: (value: unknown) => string
|
||||
yAxisFormatter?: (value: unknown) => string
|
||||
valueFormatter?: (value: number) => string
|
||||
}
|
||||
|
||||
function AreaChartComponent({
|
||||
data, xKey, yKey, series, stacked = false, gradient = true, showGrid = true,
|
||||
showLegend = false, height = 300, className, xAxisFormatter, yAxisFormatter,
|
||||
valueFormatter = (v) => v.toLocaleString(),
|
||||
}: AreaChartProps) {
|
||||
const areas = series
|
||||
? series.map((s, i) => ({ dataKey: s.key, name: s.name, color: getSeriesColor(i, s.color) }))
|
||||
: yKey ? [{ dataKey: yKey, name: yKey, color: getSeriesColor(0) }] : []
|
||||
|
||||
const gradientConfig: GradientConfig | null = gradient
|
||||
? typeof gradient === 'object' ? gradient : { from: '', to: 'transparent' }
|
||||
: null
|
||||
|
||||
return (
|
||||
<ChartContainer className={className} height={height}>
|
||||
<RechartsAreaChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||
<defs>
|
||||
{areas.map((area) => (
|
||||
<linearGradient key={area.dataKey} id={`gradient-${area.dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={gradientConfig?.from || area.color} stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor={gradientConfig?.to || area.color} stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
|
||||
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" />
|
||||
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
|
||||
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ stroke: 'hsl(var(--muted-foreground))', strokeDasharray: '3 3' }} />
|
||||
{showLegend && <Legend />}
|
||||
{areas.map((area) => (
|
||||
<Area key={area.dataKey} type="monotone" dataKey={area.dataKey} name={area.name} stroke={area.color} strokeWidth={2} fill={gradient ? `url(#gradient-${area.dataKey})` : area.color} fillOpacity={gradient ? 1 : 0.3} stackId={stacked ? 'stack' : undefined} />
|
||||
))}
|
||||
</RechartsAreaChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export const AreaChart = AreaChartComponent
|
||||
export type { AreaChartProps, GradientConfig }
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: badge
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Badge(props: BadgeProps & VariantProps<typeof badgeVariants>): JSX.Element"
|
||||
description: "Badge con 10 variantes semánticas (default, secondary, destructive, outline, ghost, link, success, warning, error, info) y 2 tamaños."
|
||||
tags: [badge, status, component, ui, indicator]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["class-variance-authority"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/badge.tsx"
|
||||
props:
|
||||
- name: variant
|
||||
type: "'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'success' | 'warning' | 'error' | 'info'"
|
||||
required: false
|
||||
description: "Variante visual"
|
||||
- name: size
|
||||
type: "'default' | 'sm'"
|
||||
required: false
|
||||
description: "Tamaño"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default, secondary, destructive, outline, ghost, link, success, warning, error, info]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/badge.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Badge variant="success">Active</Badge>
|
||||
<Badge variant="error" size="sm">Error</Badge>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Versión simplificada que usa span nativo en lugar de useRender de Base-UI. Mantiene todas las variantes y la composibilidad con cn().
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive: "bg-destructive/10 text-destructive [a]:hover:bg-destructive/20",
|
||||
outline: "border-border text-foreground [a]:hover:bg-muted",
|
||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
success: "bg-green-500/10 text-green-600 dark:bg-green-500/20 dark:text-green-400",
|
||||
warning: "bg-yellow-500/10 text-yellow-600 dark:bg-yellow-500/20 dark:text-yellow-400",
|
||||
error: "bg-red-500/10 text-red-600 dark:bg-red-500/20 dark:text-red-400",
|
||||
info: "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400",
|
||||
},
|
||||
size: {
|
||||
default: "h-5 px-2 text-xs",
|
||||
sm: "h-4 px-1.5 text-[10px]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant = "default", size = "default", ...props }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: bar_chart
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "BarChart(props: BarChartProps): JSX.Element"
|
||||
description: "Gráfico de barras Recharts con multi-series, orientación horizontal/vertical, tooltips temáticos y bordes redondeados."
|
||||
tags: [chart, bar, visualization, recharts, component, ui]
|
||||
uses_functions: [cn_typescript_core, chart_container_typescript_ui, get_series_color_typescript_core]
|
||||
uses_types: [ChartSeries_typescript_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [recharts]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/bar_chart.tsx"
|
||||
props:
|
||||
- name: data
|
||||
type: "Record<string, unknown>[]"
|
||||
required: true
|
||||
description: "Array de datos"
|
||||
- name: xKey
|
||||
type: "string"
|
||||
required: true
|
||||
description: "Key del eje X/categoría"
|
||||
- name: horizontal
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Orientación horizontal"
|
||||
- name: series
|
||||
type: "Series[]"
|
||||
required: false
|
||||
description: "Series de datos para multi-series"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [vertical, horizontal]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/charts/bar-chart.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<BarChart data={data} xKey="category" yKey="sales" showLegend />
|
||||
<BarChart data={data} xKey="name" series={series} horizontal />
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
} from 'recharts'
|
||||
import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container'
|
||||
|
||||
interface BarChartProps {
|
||||
data: Record<string, unknown>[]
|
||||
xKey: string
|
||||
yKey?: string
|
||||
series?: Series[]
|
||||
horizontal?: boolean
|
||||
showGrid?: boolean
|
||||
showLegend?: boolean
|
||||
height?: number | string
|
||||
className?: string
|
||||
xAxisFormatter?: (value: unknown) => string
|
||||
yAxisFormatter?: (value: unknown) => string
|
||||
valueFormatter?: (value: number) => string
|
||||
}
|
||||
|
||||
function BarChartComponent({
|
||||
data, xKey, yKey, series, horizontal = false, showGrid = true, showLegend = false,
|
||||
height = 300, className, xAxisFormatter, yAxisFormatter, valueFormatter = (v) => v.toLocaleString(),
|
||||
}: BarChartProps) {
|
||||
const bars = series
|
||||
? series.map((s, i) => ({ dataKey: s.key, name: s.name, fill: getSeriesColor(i, s.color) }))
|
||||
: yKey ? [{ dataKey: yKey, name: yKey, fill: getSeriesColor(0) }] : []
|
||||
|
||||
return (
|
||||
<ChartContainer className={className} height={height}>
|
||||
<RechartsBarChart data={data} layout={horizontal ? 'vertical' : 'horizontal'} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
|
||||
{horizontal ? (
|
||||
<>
|
||||
<XAxis type="number" tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
|
||||
<YAxis dataKey={xKey} type="category" tickFormatter={xAxisFormatter} width={80} className="text-xs fill-muted-foreground" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" />
|
||||
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} />
|
||||
{showLegend && <Legend />}
|
||||
{bars.map((bar) => <Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={[4, 4, 0, 0]} />)}
|
||||
</RechartsBarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export const BarChart = BarChartComponent
|
||||
export type { BarChartProps }
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: button
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Button(props: ButtonProps & VariantProps<typeof buttonVariants>): JSX.Element"
|
||||
description: "Botón accesible con 6 variantes (default, outline, secondary, ghost, destructive, link) y 8 tamaños. Base-UI primitivo con CVA."
|
||||
tags: [button, component, ui, interactive, cva]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react", "class-variance-authority"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/button.tsx"
|
||||
props:
|
||||
- name: variant
|
||||
type: "'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'"
|
||||
required: false
|
||||
description: "Estilo visual del botón"
|
||||
- name: size
|
||||
type: "'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'"
|
||||
required: false
|
||||
description: "Tamaño del botón"
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: [onClick]
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default, outline, secondary, ghost, destructive, link]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/button.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Button variant="outline" size="sm">Click me</Button>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Button variant="ghost" size="icon"><TrashIcon /></Button>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Componente base del sistema. Usa Base-UI Button primitive para accesibilidad completa (keyboard, ARIA). CVA para gestión type-safe de variantes.
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem]",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)]",
|
||||
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)]",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: card
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Card(props: { size?: 'default' | 'sm'; className?: string; children: ReactNode }): JSX.Element"
|
||||
description: "Contenedor card con header, title, description, action, content y footer. Sistema de slots composable."
|
||||
tags: [card, container, layout, component, ui]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["react"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/card.tsx"
|
||||
props:
|
||||
- name: size
|
||||
type: "'default' | 'sm'"
|
||||
required: false
|
||||
description: "Tamaño del card"
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default, sm]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/card.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Título</CardTitle>
|
||||
<CardDescription>Descripción</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>Contenido</CardContent>
|
||||
<CardFooter>Footer</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Sistema de slots via data-slot attributes. Card detecta automáticamente la presencia de CardFooter y ajusta el padding. Exporta 7 subcomponentes composables.
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: chart_container
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "ChartContainer(props: { children: ReactNode; height?: number | string }): JSX.Element"
|
||||
description: "Base para todos los charts Recharts: container responsive, tooltip temático, legend y utilidades de colores por serie."
|
||||
tags: [chart, container, recharts, base, visualization, component, ui]
|
||||
uses_functions: [cn_typescript_core, get_series_color_typescript_core]
|
||||
uses_types: [ChartSeries_typescript_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [recharts, react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/chart_container.tsx"
|
||||
props:
|
||||
- name: height
|
||||
type: "number | string"
|
||||
required: false
|
||||
description: "Altura del chart (default 300)"
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/charts/chart-base.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<ChartContainer height={400}>
|
||||
<RechartsLineChart data={data}>...</RechartsLineChart>
|
||||
</ChartContainer>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Exporta: ChartContainer, ChartTooltipContent, ChartTooltip, ChartLegend, chartColors, defaultColors, getSeriesColor, Series.
|
||||
@@ -0,0 +1,80 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../core/cn'
|
||||
import { ResponsiveContainer, Tooltip as RechartsTooltip, Legend as RechartsLegend } from 'recharts'
|
||||
|
||||
export const chartColors = [
|
||||
'hsl(var(--chart-1, 220 70% 50%))',
|
||||
'hsl(var(--chart-2, 160 60% 45%))',
|
||||
'hsl(var(--chart-3, 30 80% 55%))',
|
||||
'hsl(var(--chart-4, 280 65% 60%))',
|
||||
'hsl(var(--chart-5, 340 75% 55%))',
|
||||
]
|
||||
|
||||
export const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']
|
||||
|
||||
export interface Series {
|
||||
key: string
|
||||
name: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function getSeriesColor(index: number, color?: string): string {
|
||||
return color || defaultColors[index % defaultColors.length]
|
||||
}
|
||||
|
||||
interface ChartContainerProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
height?: number | string
|
||||
}
|
||||
|
||||
export function ChartContainer({ children, className, height = 300 }: ChartContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full', className)}
|
||||
style={{ height, minWidth: 100, minHeight: typeof height === 'number' ? Math.min(height, 100) : 100 }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{children as React.ReactElement}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChartTooltipContentProps {
|
||||
active?: boolean
|
||||
payload?: Array<{ name: string; value: number; color: string; dataKey: string }>
|
||||
label?: string
|
||||
labelFormatter?: (label: string) => string
|
||||
valueFormatter?: (value: number) => string
|
||||
}
|
||||
|
||||
export function ChartTooltipContent({
|
||||
active, payload, label,
|
||||
labelFormatter = (l) => l,
|
||||
valueFormatter = (v) => v.toLocaleString(),
|
||||
}: ChartTooltipContentProps) {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-md">
|
||||
<p className="mb-1 text-sm font-medium">{labelFormatter(label || '')}</p>
|
||||
<div className="space-y-0.5">
|
||||
{payload.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-sm">
|
||||
<div className="size-2.5 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-muted-foreground">{entry.name}:</span>
|
||||
<span className="font-medium">{valueFormatter(entry.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChartTooltip(props: React.ComponentProps<typeof RechartsTooltip>) {
|
||||
return <RechartsTooltip content={<ChartTooltipContent />} cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} {...props} />
|
||||
}
|
||||
|
||||
export function ChartLegend(props: React.ComponentProps<typeof RechartsLegend>) {
|
||||
return <RechartsLegend wrapperStyle={{ paddingTop: 16 }} {...props} />
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: crud_page
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "crudPage<T>(props: CrudPageProps<T>): ReactElement"
|
||||
description: "Genera una página CRUD completa con header, tabla con columnas configurables, botones de acción (add/edit/delete) y schema de formulario."
|
||||
tags: [crud, page, table, form, factory, composition, ui]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/crud_page.tsx"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: ""
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
crudPage({
|
||||
title: 'Users',
|
||||
subtitle: 'Manage system users',
|
||||
data: users,
|
||||
fields: [
|
||||
{ key: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ key: 'email', label: 'Email', type: 'email', required: true },
|
||||
{ key: 'role', label: 'Role', type: 'select', options: [{ label: 'Admin', value: 'admin' }, { label: 'User', value: 'user' }] },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'role', label: 'Role', render: (v) => <Badge variant={v === 'admin' ? 'default' : 'secondary'}>{v}</Badge> },
|
||||
],
|
||||
onAdd: handleAdd,
|
||||
onEdit: handleEdit,
|
||||
onDelete: handleDelete,
|
||||
})
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
El schema de campos se almacena como data attribute para que un agente pueda leerlo y generar el formulario de diálogo correspondiente. La tabla incluye sorting visual implícito por columnas.
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../core/cn'
|
||||
|
||||
interface CrudField {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'number' | 'email' | 'select' | 'textarea'
|
||||
required?: boolean
|
||||
options?: Array<{ label: string; value: string }>
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface CrudPageProps<T extends Record<string, unknown>> {
|
||||
title: string
|
||||
subtitle?: string
|
||||
data: T[]
|
||||
fields: CrudField[]
|
||||
columns: Array<{
|
||||
key: keyof T
|
||||
label: string
|
||||
render?: (value: unknown, row: T) => React.ReactNode
|
||||
}>
|
||||
onAdd?: (item: Partial<T>) => void
|
||||
onEdit?: (item: T) => void
|
||||
onDelete?: (item: T) => void
|
||||
actions?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function crudPage<T extends Record<string, unknown>>({
|
||||
title,
|
||||
subtitle,
|
||||
data,
|
||||
fields,
|
||||
columns,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
actions,
|
||||
className,
|
||||
}: CrudPageProps<T>): React.ReactElement {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
{onAdd && (
|
||||
<button className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-2.5 text-sm font-medium text-primary-foreground hover:bg-primary/80">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Add {title.replace(/s$/, '')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<thead className="border-b bg-muted/50">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={String(col.key)} className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
{(onEdit || onDelete) && (
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">Actions</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (onEdit || onDelete ? 1 : 0)} className="h-24 text-center text-muted-foreground">
|
||||
No items yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<td key={String(col.key)} className="px-4 py-3 align-middle">
|
||||
{col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
{(onEdit || onDelete) && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{onEdit && (
|
||||
<button onClick={() => onEdit(row)} className="inline-flex size-7 items-center justify-center rounded-md hover:bg-muted">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button onClick={() => onDelete(row)} className="inline-flex size-7 items-center justify-center rounded-md text-destructive hover:bg-destructive/10">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Form fields definition (for agent use — renders a form preview) */}
|
||||
<div className="hidden" data-slot="crud-form-schema" data-fields={JSON.stringify(fields)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { CrudPageProps, CrudField }
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: dashboard_layout
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "dashboardLayout(props: DashboardLayoutProps): ReactElement"
|
||||
description: "Genera un grid responsive de dashboard a partir de un array de widgets con span configurable. 1-4 columnas con auto-responsive."
|
||||
tags: [dashboard, layout, grid, factory, composition, ui]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/dashboard_layout.tsx"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: ""
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
dashboardLayout({
|
||||
columns: 4,
|
||||
widgets: [
|
||||
{ id: 'revenue', title: 'Revenue', content: <KPICard label="Revenue" value="$12k" /> },
|
||||
{ id: 'users', title: 'Users', content: <KPICard label="Users" value={1234} /> },
|
||||
{ id: 'chart', title: 'Trends', span: 2, content: <LineChart data={data} xKey="month" yKey="value" /> },
|
||||
{ id: 'table', span: 4, content: <DataTable columns={cols} data={rows} /> },
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Factory pura — dado el mismo input siempre genera el mismo JSX. Un agente puede construir dashboards completos pasando widgets como configuración declarativa.
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../core/cn'
|
||||
|
||||
interface DashboardWidget {
|
||||
id: string
|
||||
title?: string
|
||||
span?: 1 | 2 | 3 | 4
|
||||
rowSpan?: 1 | 2
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
widgets: DashboardWidget[]
|
||||
columns?: 1 | 2 | 3 | 4
|
||||
gap?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const gapClasses = { sm: 'gap-2', md: 'gap-4', lg: 'gap-6' }
|
||||
|
||||
const spanClasses: Record<number, string> = {
|
||||
1: 'col-span-1',
|
||||
2: 'col-span-1 md:col-span-2',
|
||||
3: 'col-span-1 md:col-span-2 lg:col-span-3',
|
||||
4: 'col-span-1 md:col-span-2 lg:col-span-4',
|
||||
}
|
||||
|
||||
const rowSpanClasses: Record<number, string> = {
|
||||
1: 'row-span-1',
|
||||
2: 'row-span-2',
|
||||
}
|
||||
|
||||
export function dashboardLayout({
|
||||
widgets,
|
||||
columns = 4,
|
||||
gap = 'md',
|
||||
className,
|
||||
}: DashboardLayoutProps): React.ReactElement {
|
||||
const gridCols: Record<number, string> = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid', gridCols[columns], gapClasses[gap], className)}>
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card p-4 text-card-foreground shadow-sm',
|
||||
spanClasses[widget.span || 1],
|
||||
rowSpanClasses[widget.rowSpan || 1]
|
||||
)}
|
||||
>
|
||||
{widget.title && (
|
||||
<h3 className="mb-3 text-sm font-medium text-muted-foreground">{widget.title}</h3>
|
||||
)}
|
||||
{widget.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { DashboardWidget, DashboardLayoutProps }
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: detail_page
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "detailPage(props: DetailPageProps): ReactElement"
|
||||
description: "Genera una página de detalle de entidad con header (avatar, badge, back), grid de campos, tabs con contadores y timeline de actividad."
|
||||
tags: [detail, page, entity, timeline, factory, composition, ui]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/detail_page.tsx"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: ""
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
detailPage({
|
||||
title: 'John Doe',
|
||||
subtitle: 'john@example.com',
|
||||
badge: <Badge variant="success">Active</Badge>,
|
||||
onBack: () => router.back(),
|
||||
fields: [
|
||||
{ label: 'Role', value: 'Administrator' },
|
||||
{ label: 'Created', value: 'Mar 15, 2026' },
|
||||
{ label: 'Bio', value: 'Full stack developer...', span: 2 },
|
||||
],
|
||||
tabs: [
|
||||
{ label: 'Projects', value: 'projects', count: 12, content: <ProjectList /> },
|
||||
{ label: 'Activity', value: 'activity', count: 48, content: <ActivityList /> },
|
||||
],
|
||||
activeTab: 'projects',
|
||||
timeline: [
|
||||
{ id: '1', title: 'Deployed v2.1', timestamp: '2 hours ago', variant: 'success' },
|
||||
{ id: '2', title: 'Updated settings', timestamp: 'Yesterday' },
|
||||
{ id: '3', title: 'Created project', timestamp: 'Mar 10, 2026' },
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Factory completa para páginas de detalle. Combina header con back/avatar/badge, grid de metadata, tabs con badges de conteo, y timeline de actividad con variantes de color semántico.
|
||||
@@ -0,0 +1,134 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../core/cn'
|
||||
|
||||
interface DetailField {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
span?: 1 | 2
|
||||
}
|
||||
|
||||
interface DetailTab {
|
||||
label: string
|
||||
value: string
|
||||
content: React.ReactNode
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface TimelineEvent {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
timestamp: string
|
||||
icon?: React.ReactNode
|
||||
variant?: 'default' | 'success' | 'warning' | 'error'
|
||||
}
|
||||
|
||||
interface DetailPageProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
badge?: React.ReactNode
|
||||
avatar?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
onBack?: () => void
|
||||
fields: DetailField[]
|
||||
tabs?: DetailTab[]
|
||||
activeTab?: string
|
||||
onTabChange?: (value: string) => void
|
||||
timeline?: TimelineEvent[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantDotColors = {
|
||||
default: 'bg-primary',
|
||||
success: 'bg-green-500',
|
||||
warning: 'bg-amber-500',
|
||||
error: 'bg-red-500',
|
||||
}
|
||||
|
||||
export function detailPage({
|
||||
title, subtitle, badge, avatar, actions, onBack,
|
||||
fields, tabs, activeTab, onTabChange, timeline, className,
|
||||
}: DetailPageProps): React.ReactElement {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between border-b pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{onBack && (
|
||||
<button onClick={onBack} className="mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-muted">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m15 18-6-6 6-6"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{avatar && <div className="size-12 shrink-0 overflow-hidden rounded-full bg-muted">{avatar}</div>}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{badge}
|
||||
</div>
|
||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
|
||||
{/* Fields grid */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} className={cn('space-y-1', field.span === 2 && 'md:col-span-2')}>
|
||||
<p className="text-sm text-muted-foreground">{field.label}</p>
|
||||
<div className="text-sm font-medium">{field.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{tabs && tabs.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<nav className="flex gap-4 border-b">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => onTabChange?.(tab.value)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 border-b-2 px-1 pb-3 text-sm font-medium transition-colors',
|
||||
activeTab === tab.value ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span className="inline-flex h-5 items-center rounded-full bg-muted px-2 text-xs font-medium">{tab.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{tabs.find(t => t.value === activeTab)?.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{timeline && timeline.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Activity</h3>
|
||||
<div className="space-y-0">
|
||||
{timeline.map((event, i) => (
|
||||
<div key={event.id} className="flex gap-3 pb-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={cn('mt-1 size-2.5 rounded-full', variantDotColors[event.variant || 'default'])} />
|
||||
{i < timeline.length - 1 && <div className="flex-1 w-px bg-border" />}
|
||||
</div>
|
||||
<div className="flex-1 space-y-0.5 pb-2">
|
||||
<p className="text-sm font-medium">{event.title}</p>
|
||||
{event.description && <p className="text-xs text-muted-foreground">{event.description}</p>}
|
||||
<p className="text-xs text-muted-foreground/70">{event.timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { DetailPageProps, DetailField, DetailTab, TimelineEvent }
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: dialog
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Dialog(props: DialogRootProps): JSX.Element"
|
||||
description: "Diálogo modal accesible con overlay blur, animaciones, close button y sistema de slots (header, footer, title, description)."
|
||||
tags: [dialog, modal, overlay, component, ui, interactive]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react", lucide-react, react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/dialog.tsx"
|
||||
props:
|
||||
- name: showCloseButton
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Mostrar botón de cerrar (default true)"
|
||||
emits: [onOpenChange]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/dialog.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Dialog>
|
||||
<DialogTrigger asChild><Button>Open</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Título</DialogTitle>
|
||||
<DialogDescription>Descripción</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p>Contenido</p>
|
||||
<DialogFooter>
|
||||
<Button>Confirmar</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
10 subcomponentes exportados. Base-UI Dialog primitive para accesibilidad completa (focus trap, escape, click outside).
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
import { cn } from "../core/cn"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn("fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({ className, children, showCloseButton = true, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean }) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn("fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" className="absolute top-2 right-2 inline-flex size-7 items-center justify-center rounded-md hover:bg-muted">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
}
|
||||
|
||||
function DialogFooter({ className, children, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="dialog-footer" className={cn("-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return <DialogPrimitive.Title data-slot="dialog-title" className={cn("text-base leading-none font-medium", className)} {...props} />
|
||||
}
|
||||
|
||||
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
|
||||
return <DialogPrimitive.Description data-slot="dialog-description" className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger }
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: form_field
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "FormField(props: FormFieldProps): JSX.Element"
|
||||
description: "Wrapper de campo de formulario con label, helper text, error y ARIA automáticos. Inyecta id y aria-describedby a hijos."
|
||||
tags: [form, field, label, error, component, ui, accessibility]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/form_field.tsx"
|
||||
props:
|
||||
- name: label
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Texto del label"
|
||||
- name: helperText
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Texto de ayuda"
|
||||
- name: error
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Mensaje de error (reemplaza helperText)"
|
||||
- name: children
|
||||
type: "ReactNode"
|
||||
required: true
|
||||
description: "Input o componente de formulario"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/form-field.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<FormField label="Email" helperText="Tu email corporativo" error={errors.email}>
|
||||
<Input type="email" />
|
||||
</FormField>
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
interface FormFieldProps {
|
||||
label?: string
|
||||
helperText?: string
|
||||
error?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function FormField({ label, helperText, error, children, className }: FormFieldProps) {
|
||||
const id = React.useId()
|
||||
const inputId = `${id}-input`
|
||||
const helperId = `${id}-helper`
|
||||
const errorId = `${id}-error`
|
||||
|
||||
const describedBy = [helperText ? helperId : null, error ? errorId : null].filter(Boolean).join(" ") || undefined
|
||||
|
||||
const childWithProps = React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return React.cloneElement(child as React.ReactElement<Record<string, unknown>>, {
|
||||
id: inputId,
|
||||
"aria-invalid": error ? true : undefined,
|
||||
"aria-describedby": describedBy,
|
||||
})
|
||||
}
|
||||
return child
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
{label && <label htmlFor={inputId} className="text-sm font-medium text-foreground">{label}</label>}
|
||||
{childWithProps}
|
||||
{helperText && !error && <p id={helperId} className="text-sm text-muted-foreground">{helperText}</p>}
|
||||
{error && <p id={errorId} className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { FormField }
|
||||
export type { FormFieldProps }
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: input
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Input(props: InputHTMLAttributes): JSX.Element"
|
||||
description: "Campo de entrada accesible con soporte para iconos, grupos, validación ARIA y estados disabled/invalid."
|
||||
tags: [input, form, component, ui, interactive]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react", "react"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/input.tsx"
|
||||
props:
|
||||
- name: type
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Tipo de input HTML"
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: [onChange, onFocus, onBlur]
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/input.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Input placeholder="Email" type="email" />
|
||||
<InputGroup>
|
||||
<InputIcon position="start"><SearchIcon /></InputIcon>
|
||||
<Input placeholder="Buscar..." />
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Exporta Input, InputGroup e InputIcon. InputGroup detecta automáticamente la presencia de iconos y ajusta padding del Input.
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
"group-has-[data-slot=input-icon-start]/input-group:pl-9",
|
||||
"group-has-[data-slot=input-icon-end]/input-group:pr-9",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface InputGroupProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function InputGroup({ children, className }: InputGroupProps) {
|
||||
return (
|
||||
<div data-slot="input-group" className={cn("group/input-group relative", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface InputIconProps {
|
||||
children: React.ReactNode
|
||||
position: "start" | "end"
|
||||
className?: string
|
||||
}
|
||||
|
||||
function InputIcon({ children, position, className }: InputIconProps) {
|
||||
return (
|
||||
<span
|
||||
data-slot={`input-icon-${position}`}
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground [&_svg]:size-4",
|
||||
position === "start" && "left-2.5",
|
||||
position === "end" && "right-2.5",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input, InputGroup, InputIcon }
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: kpi_card
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "KPICard(props: KPICardProps): JSX.Element"
|
||||
description: "Card de KPI con label, valor, delta porcentual con color semántico, icono y subtítulo. 3 tamaños."
|
||||
tags: [kpi, card, metrics, dashboard, component, ui]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/kpi_card.tsx"
|
||||
props:
|
||||
- name: label
|
||||
type: "string"
|
||||
required: true
|
||||
description: "Etiqueta del KPI"
|
||||
- name: value
|
||||
type: "string | number"
|
||||
required: true
|
||||
description: "Valor principal"
|
||||
- name: delta
|
||||
type: "{ value: number; isPositive: boolean }"
|
||||
required: false
|
||||
description: "Cambio porcentual con dirección"
|
||||
- name: icon
|
||||
type: "ReactNode"
|
||||
required: false
|
||||
description: "Icono decorativo"
|
||||
- name: size
|
||||
type: "'sm' | 'default' | 'lg'"
|
||||
required: false
|
||||
description: "Tamaño"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [sm, default, lg]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/kpi-card.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<KPICard label="Revenue" value="$12,450" delta={{ value: 12.5, isPositive: true }} />
|
||||
<KPICard label="Users" value={1234} size="lg" icon={<UsersIcon />} />
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../core/cn'
|
||||
|
||||
type KPICardSize = 'sm' | 'default' | 'lg'
|
||||
|
||||
interface Delta {
|
||||
value: number
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
interface KPICardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
label: string
|
||||
value: string | number
|
||||
delta?: Delta
|
||||
icon?: React.ReactNode
|
||||
subtitle?: string
|
||||
size?: KPICardSize
|
||||
}
|
||||
|
||||
const sizeStyles: Record<KPICardSize, { value: string; label: string }> = {
|
||||
sm: { value: 'text-2xl font-bold', label: 'text-xs' },
|
||||
default: { value: 'text-3xl font-bold', label: 'text-sm' },
|
||||
lg: { value: 'text-4xl font-bold', label: 'text-base' },
|
||||
}
|
||||
|
||||
const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
|
||||
({ label, value, delta, icon, subtitle, size = 'default', className, ...props }, ref) => {
|
||||
const styles = sizeStyles[size]
|
||||
const deltaColor = delta
|
||||
? delta.value === 0 ? 'text-muted-foreground'
|
||||
: delta.isPositive ? 'text-green-600 dark:text-green-500'
|
||||
: 'text-red-600 dark:text-red-500'
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('rounded-lg border bg-card p-4 text-card-foreground shadow-sm', className)} {...props}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className={cn('text-muted-foreground', styles.label)}>{label}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground/80">{subtitle}</p>}
|
||||
</div>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
</div>
|
||||
<div className="mt-3 flex items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className={cn('tracking-tight', styles.value)}>{value}</p>
|
||||
{delta && (
|
||||
<div className={cn('flex items-center gap-1 text-sm font-medium', deltaColor)}>
|
||||
<span>{delta.value > 0 ? '+' : ''}{delta.value}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
KPICard.displayName = 'KPICard'
|
||||
|
||||
export { KPICard, type KPICardProps, type Delta, type KPICardSize }
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: label
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Label(props: LabelHTMLAttributes): JSX.Element"
|
||||
description: "Etiqueta de formulario accesible con soporte para estados disabled y peer-disabled."
|
||||
tags: [label, form, component, ui]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["react"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/label.tsx"
|
||||
props:
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/label.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Label htmlFor="email">Email</Label>
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: line_chart
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "LineChart(props: LineChartProps): JSX.Element"
|
||||
description: "Gráfico de líneas Recharts con multi-series, 5 tipos de curva, zoom brush, líneas de referencia, tooltips temáticos."
|
||||
tags: [chart, line, visualization, recharts, component, ui]
|
||||
uses_functions: [cn_typescript_core, chart_container_typescript_ui, get_series_color_typescript_core]
|
||||
uses_types: [ChartSeries_typescript_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [recharts]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/line_chart.tsx"
|
||||
props:
|
||||
- name: data
|
||||
type: "Record<string, unknown>[]"
|
||||
required: true
|
||||
description: "Array de datos"
|
||||
- name: xKey
|
||||
type: "string"
|
||||
required: true
|
||||
description: "Key del eje X"
|
||||
- name: series
|
||||
type: "Series[]"
|
||||
required: false
|
||||
description: "Series de datos"
|
||||
- name: zoomable
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Habilitar zoom brush"
|
||||
- name: curveType
|
||||
type: "'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'"
|
||||
required: false
|
||||
description: "Tipo de curva (default monotone)"
|
||||
- name: referenceLines
|
||||
type: "Array<{ y: number; label?: string; color?: string }>"
|
||||
required: false
|
||||
description: "Líneas de referencia horizontales"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/charts/line-chart.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<LineChart
|
||||
data={salesData}
|
||||
xKey="month"
|
||||
series={[
|
||||
{ key: "revenue", name: "Revenue", color: "#3b82f6" },
|
||||
{ key: "cost", name: "Cost", color: "#ef4444" },
|
||||
]}
|
||||
zoomable
|
||||
showLegend
|
||||
/>
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, Legend, Brush, ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container'
|
||||
|
||||
type CurveType = 'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'
|
||||
|
||||
interface LineChartProps {
|
||||
data: Record<string, unknown>[]
|
||||
xKey: string
|
||||
yKey?: string
|
||||
series?: Series[]
|
||||
curveType?: CurveType
|
||||
showGrid?: boolean
|
||||
showLegend?: boolean
|
||||
showDots?: boolean
|
||||
zoomable?: boolean
|
||||
height?: number | string
|
||||
className?: string
|
||||
xAxisFormatter?: (value: unknown) => string
|
||||
yAxisFormatter?: (value: unknown) => string
|
||||
valueFormatter?: (value: number) => string
|
||||
referenceLines?: Array<{ y: number; label?: string; color?: string }>
|
||||
}
|
||||
|
||||
function LineChartComponent({
|
||||
data, xKey, yKey, series, curveType = 'monotone', showGrid = true, showLegend = false,
|
||||
showDots = true, zoomable = false, height = 300, className, xAxisFormatter, yAxisFormatter,
|
||||
valueFormatter = (v) => v.toLocaleString(), referenceLines = [],
|
||||
}: LineChartProps) {
|
||||
const lines = series
|
||||
? series.map((s, i) => ({ dataKey: s.key, name: s.name, stroke: getSeriesColor(i, s.color) }))
|
||||
: yKey ? [{ dataKey: yKey, name: yKey, stroke: getSeriesColor(0) }] : []
|
||||
|
||||
return (
|
||||
<ChartContainer className={className} height={height}>
|
||||
<RechartsLineChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: zoomable ? 30 : 10 }}>
|
||||
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
|
||||
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" />
|
||||
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
|
||||
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ stroke: 'hsl(var(--muted-foreground))', strokeDasharray: '3 3' }} />
|
||||
{showLegend && <Legend />}
|
||||
{referenceLines.map((ref, i) => (
|
||||
<ReferenceLine key={i} y={ref.y} stroke={ref.color || 'hsl(var(--muted-foreground))'} strokeDasharray="3 3" label={ref.label ? { value: ref.label, position: 'right' } : undefined} />
|
||||
))}
|
||||
{lines.map((line) => (
|
||||
<Line key={line.dataKey} type={curveType} dataKey={line.dataKey} name={line.name} stroke={line.stroke} strokeWidth={2} dot={showDots ? { r: 3, fill: line.stroke } : false} activeDot={{ r: 5, fill: line.stroke }} />
|
||||
))}
|
||||
{zoomable && <Brush dataKey={xKey} height={20} stroke="hsl(var(--primary))" fill="hsl(var(--muted))" tickFormatter={xAxisFormatter} />}
|
||||
</RechartsLineChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export const LineChart = LineChartComponent
|
||||
export type { LineChartProps, CurveType }
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: page_header
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "PageHeader(props: PageHeaderProps): JSX.Element"
|
||||
description: "Cabecera de página con título, subtítulo, acciones, back button, tabs integrados, badge y modo sticky. Incluye SimplePageHeader."
|
||||
tags: [header, page, layout, navigation, component, ui]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/page_header.tsx"
|
||||
props:
|
||||
- name: title
|
||||
type: "string"
|
||||
required: true
|
||||
description: "Título principal"
|
||||
- name: subtitle
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Subtítulo"
|
||||
- name: actions
|
||||
type: "ReactNode"
|
||||
required: false
|
||||
description: "Botones de acción"
|
||||
- name: tabs
|
||||
type: "TabItem[]"
|
||||
required: false
|
||||
description: "Tabs de navegación integrados"
|
||||
- name: sticky
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Header fijo al scroll"
|
||||
emits: [onBack, onTabChange]
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [full, simple]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/page-header.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Vista general"
|
||||
actions={<Button>Export</Button>}
|
||||
tabs={[{ label: "Overview", value: "overview" }, { label: "Analytics", value: "analytics" }]}
|
||||
activeTab="overview"
|
||||
onTabChange={setTab}
|
||||
/>
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
interface TabItem {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title: string
|
||||
subtitle?: string
|
||||
actions?: React.ReactNode
|
||||
onBack?: () => void
|
||||
tabs?: TabItem[]
|
||||
activeTab?: string
|
||||
onTabChange?: (value: string) => void
|
||||
badge?: React.ReactNode
|
||||
sticky?: boolean
|
||||
}
|
||||
|
||||
function PageHeader({
|
||||
title, subtitle, actions, onBack, tabs, activeTab, onTabChange,
|
||||
badge, sticky = false, className, ...props
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
data-slot="page-header"
|
||||
className={cn("space-y-4 border-b bg-background pb-4", sticky && "sticky top-0 z-20", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{onBack && (
|
||||
<button onClick={onBack} className="mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-muted">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m15 18-6-6 6-6"/></svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{badge}
|
||||
</div>
|
||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{tabs && tabs.length > 0 && (
|
||||
<nav className="flex gap-4 border-b -mb-4 pb-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
disabled={tab.disabled}
|
||||
onClick={() => onTabChange?.(tab.value)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 border-b-2 px-1 pb-3 text-sm font-medium transition-colors",
|
||||
activeTab === tab.value ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
tab.disabled && "pointer-events-none opacity-50"
|
||||
)}
|
||||
>
|
||||
{tab.icon && <span className="size-4">{tab.icon}</span>}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface SimplePageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title: string
|
||||
description?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
function SimplePageHeader({ title, description, children, className, ...props }: SimplePageHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between border-b pb-4", className)} {...props}>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{children && <div className="flex items-center gap-2">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { PageHeader, SimplePageHeader }
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: progress_bar
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "ProgressBar(props: ProgressBarProps): JSX.Element"
|
||||
description: "Barra de progreso con variantes de color y tamaño, buffer, animación, modo indeterminado y display de valor."
|
||||
tags: [progress, loading, component, ui, feedback]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [class-variance-authority]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/progress_bar.tsx"
|
||||
props:
|
||||
- name: value
|
||||
type: "number"
|
||||
required: true
|
||||
description: "Valor actual de progreso"
|
||||
- name: max
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Valor máximo (default 100)"
|
||||
- name: buffer
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Valor de buffer secundario (opcional)"
|
||||
- name: showValue
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Mostrar porcentaje como texto superpuesto"
|
||||
- name: animated
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Activar animación de rayas (stripes)"
|
||||
- name: indeterminate
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Modo indeterminado sin valor conocido"
|
||||
- name: size
|
||||
type: "'sm' | 'md' | 'lg'"
|
||||
required: false
|
||||
description: "Altura de la barra (default md)"
|
||||
- name: color
|
||||
type: "'primary' | 'success' | 'warning' | 'destructive'"
|
||||
required: false
|
||||
description: "Color semántico (default primary)"
|
||||
- name: label
|
||||
type: "string"
|
||||
required: false
|
||||
description: "aria-label para accesibilidad"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [primary, success, warning, destructive]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/progress/progress-bar.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<ProgressBar value={75} color="success" showValue />
|
||||
<ProgressBar value={0} indeterminate />
|
||||
<ProgressBar value={50} buffer={80} animated />
|
||||
<ProgressBar value={30} size="lg" color="warning" />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
El porcentaje se clampea a [0, 100] internamente. El buffer se renderiza como capa semitransparente detrás del fill.
|
||||
Las animaciones de stripes e indeterminate requieren keyframes definidos en el CSS global:
|
||||
- `progress-stripes`: background-position de 0 a 1rem
|
||||
- `progress-indeterminate`: translateX de -100% a 300%
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '../core/cn'
|
||||
|
||||
const progressBarVariants = cva(
|
||||
'relative w-full overflow-hidden rounded-full bg-muted',
|
||||
{
|
||||
variants: {
|
||||
size: { sm: 'h-1', md: 'h-2', lg: 'h-3' },
|
||||
color: {
|
||||
primary: '[&_.progress-fill]:bg-primary',
|
||||
success: '[&_.progress-fill]:bg-emerald-500',
|
||||
warning: '[&_.progress-fill]:bg-amber-500',
|
||||
destructive: '[&_.progress-fill]:bg-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: { size: 'md', color: 'primary' },
|
||||
}
|
||||
)
|
||||
|
||||
export interface ProgressBarProps extends VariantProps<typeof progressBarVariants> {
|
||||
value: number
|
||||
max?: number
|
||||
buffer?: number
|
||||
showValue?: boolean
|
||||
animated?: boolean
|
||||
indeterminate?: boolean
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
value, max = 100, buffer, showValue = false, animated = false,
|
||||
indeterminate = false, size = 'md', color = 'primary', label, className,
|
||||
}: ProgressBarProps) {
|
||||
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
|
||||
const bufferPercentage = buffer ? Math.min(100, Math.max(0, (buffer / max) * 100)) : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="progress-bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={indeterminate ? undefined : value}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-label={label}
|
||||
className={cn(progressBarVariants({ size, color }), className)}
|
||||
>
|
||||
{bufferPercentage !== undefined && (
|
||||
<div className="progress-buffer absolute inset-0 bg-current opacity-20 transition-all duration-300" style={{ width: `${bufferPercentage}%` }} />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'progress-fill h-full transition-all duration-300',
|
||||
animated && 'bg-[length:1rem_1rem] bg-[linear-gradient(45deg,rgba(255,255,255,.15)_25%,transparent_25%,transparent_50%,rgba(255,255,255,.15)_50%,rgba(255,255,255,.15)_75%,transparent_75%,transparent)] animate-[progress-stripes_1s_linear_infinite]',
|
||||
indeterminate && 'w-1/3 animate-[progress-indeterminate_1.5s_ease-in-out_infinite]'
|
||||
)}
|
||||
style={indeterminate ? undefined : { width: `${percentage}%` }}
|
||||
/>
|
||||
{showValue && !indeterminate && (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-foreground mix-blend-difference">
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { progressBarVariants }
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: select
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Select<T>(props: SelectRootProps<T>): JSX.Element"
|
||||
description: "Select genérico accesible con grupos, separadores y animaciones. Base-UI primitive con posicionamiento automático."
|
||||
tags: [select, form, dropdown, component, ui, interactive]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react", lucide-react, react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/select.tsx"
|
||||
props:
|
||||
- name: value
|
||||
type: "T"
|
||||
required: false
|
||||
description: "Valor seleccionado (controlled)"
|
||||
- name: onValueChange
|
||||
type: "(value: T) => void"
|
||||
required: false
|
||||
description: "Callback al cambiar selección"
|
||||
- name: defaultValue
|
||||
type: "T"
|
||||
required: false
|
||||
description: "Valor inicial (uncontrolled)"
|
||||
- name: disabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Deshabilitar el select"
|
||||
emits: [onValueChange]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/select.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger><SelectValue placeholder="Elegir..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectGroupLabel>Frutas</SelectGroupLabel>
|
||||
<SelectItem value="apple">Manzana</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectItem value="other">Otro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Exporta 9 subcomponentes composables: Select, SelectTrigger, SelectValue, SelectPortal, SelectContent, SelectGroup, SelectGroupLabel, SelectItem, SelectSeparator.
|
||||
Genérico sobre el tipo de valor T — TypeScript infiere el tipo desde el prop value/defaultValue.
|
||||
Depende de @base-ui/react y lucide-react.
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
import { ChevronDown, Check } from "lucide-react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
function Select<T>({ ...props }: SelectPrimitive.Root.Props<T>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: SelectPrimitive.Value.Props) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectPortal({ ...props }: SelectPrimitive.Portal.Props) {
|
||||
return <SelectPrimitive.Portal data-slot="select-portal" {...props} />
|
||||
}
|
||||
|
||||
function SelectContent({ className, children, ...props }: SelectPrimitive.Positioner.Props) {
|
||||
return (
|
||||
<SelectPortal>
|
||||
<SelectPrimitive.Positioner
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-[300px] min-w-[8rem] overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md",
|
||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
|
||||
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
sideOffset={4}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Popup className="w-full p-1">{children}</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: SelectPrimitive.Group.Props) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroupLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
|
||||
return <SelectPrimitive.GroupLabel data-slot="select-group-label" className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground", className)} {...props} />
|
||||
}
|
||||
|
||||
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator><Check className="size-4" /></SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="select-separator" className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Select, SelectContent, SelectGroup, SelectGroupLabel, SelectItem, SelectPortal, SelectSeparator, SelectTrigger, SelectValue }
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: settings_page
|
||||
kind: function
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "settingsPage(props: SettingsPageProps): ReactElement"
|
||||
description: "Genera una página de configuración con navegación lateral, secciones y campos de formulario (text, number, toggle, select, textarea)."
|
||||
tags: [settings, page, form, sections, factory, composition, ui]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/settings_page.tsx"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: ""
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
settingsPage({
|
||||
title: 'Project Settings',
|
||||
sections: [
|
||||
{
|
||||
id: 'general',
|
||||
title: 'General',
|
||||
description: 'Basic project configuration',
|
||||
fields: [
|
||||
{ key: 'name', label: 'Project Name', type: 'text', value: 'My Project' },
|
||||
{ key: 'visibility', label: 'Public', description: 'Make project visible to everyone', type: 'toggle', value: true },
|
||||
{ key: 'language', label: 'Language', type: 'select', options: [{ label: 'English', value: 'en' }, { label: 'Spanish', value: 'es' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
title: 'Notifications',
|
||||
fields: [
|
||||
{ key: 'email', label: 'Email notifications', type: 'toggle', value: false },
|
||||
{ key: 'webhook', label: 'Webhook URL', type: 'text', placeholder: 'https://...' },
|
||||
],
|
||||
},
|
||||
],
|
||||
onSave: handleSave,
|
||||
})
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Layout de settings estándar con sidebar de navegación (oculta en mobile). Secciones con anclas para scroll. Soporta 5 tipos de campo.
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../core/cn'
|
||||
|
||||
interface SettingField {
|
||||
key: string
|
||||
label: string
|
||||
description?: string
|
||||
type: 'text' | 'number' | 'toggle' | 'select' | 'textarea'
|
||||
value?: unknown
|
||||
options?: Array<{ label: string; value: string }>
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface SettingSection {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
fields: SettingField[]
|
||||
}
|
||||
|
||||
interface SettingsPageProps {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
sections: SettingSection[]
|
||||
onSave?: (values: Record<string, unknown>) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function settingsPage({
|
||||
title = 'Settings',
|
||||
subtitle,
|
||||
sections,
|
||||
onSave,
|
||||
className,
|
||||
}: SettingsPageProps): React.ReactElement {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
{/* Header */}
|
||||
<div className="border-b pb-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
{/* Tabs navigation */}
|
||||
<div className="flex gap-6">
|
||||
<nav className="hidden w-48 shrink-0 md:block">
|
||||
<div className="space-y-1">
|
||||
{sections.map((section) => (
|
||||
<a
|
||||
key={section.id}
|
||||
href={`#${section.id}`}
|
||||
className="block rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{section.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="flex-1 space-y-8">
|
||||
{sections.map((section) => (
|
||||
<div key={section.id} id={section.id} className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">{section.title}</h2>
|
||||
{section.description && <p className="text-sm text-muted-foreground">{section.description}</p>}
|
||||
</div>
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
{section.fields.map((field) => (
|
||||
<div key={field.key} className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<label className="text-sm font-medium">{field.label}</label>
|
||||
{field.description && <p className="text-xs text-muted-foreground">{field.description}</p>}
|
||||
</div>
|
||||
<div className="w-full sm:w-64">
|
||||
{field.type === 'toggle' ? (
|
||||
<button className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors',
|
||||
field.value ? 'bg-primary' : 'bg-input'
|
||||
)}>
|
||||
<span className={cn('pointer-events-none block size-4 rounded-full bg-background shadow-lg ring-0 transition-transform', field.value ? 'translate-x-4' : 'translate-x-0')} />
|
||||
</button>
|
||||
) : field.type === 'select' ? (
|
||||
<select className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
{field.options?.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
) : field.type === 'textarea' ? (
|
||||
<textarea className="w-full rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-sm" rows={3} placeholder={field.placeholder} defaultValue={String(field.value ?? '')} />
|
||||
) : (
|
||||
<input type={field.type} className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm" placeholder={field.placeholder} defaultValue={String(field.value ?? '')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{onSave && (
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<button className="inline-flex h-8 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/80">
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { SettingsPageProps, SettingSection, SettingField }
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: skeleton
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Skeleton(props: HTMLAttributes<HTMLDivElement>): JSX.Element"
|
||||
description: "Sistema de loading skeletons: base, text, card, avatar, button, table. Variantes preconfiguradas para estados de carga."
|
||||
tags: [skeleton, loading, placeholder, component, ui]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/skeleton.tsx"
|
||||
props:
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
- name: lines
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Número de líneas (SkeletonText, default 3)"
|
||||
- name: rows
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Filas de tabla (SkeletonTable, default 5)"
|
||||
- name: columns
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Columnas de tabla (SkeletonTable, default 4)"
|
||||
- name: size
|
||||
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
|
||||
required: false
|
||||
description: "Tamaño del avatar (SkeletonAvatar, default md)"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [base, text, card, avatar, button, table]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/skeleton.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<SkeletonCard />
|
||||
<SkeletonText lines={4} />
|
||||
<SkeletonTable rows={10} columns={5} />
|
||||
<SkeletonAvatar size="lg" />
|
||||
<SkeletonButton />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Exporta 6 variantes preconfiguradas. Todas componen sobre el Skeleton base con animate-pulse.
|
||||
La última línea de SkeletonText se acorta a w-4/5 para simular texto real.
|
||||
SkeletonCard incluye imagen (h-[180px]) + dos líneas de texto.
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />
|
||||
}
|
||||
|
||||
function SkeletonText({ className, lines = 3, ...props }: React.HTMLAttributes<HTMLDivElement> & { lines?: number }) {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton key={i} className={cn("h-4", i === lines - 1 ? "w-4/5" : "w-full")} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SkeletonCard({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("space-y-3", className)} {...props}>
|
||||
<Skeleton className="h-[180px] w-full rounded-lg" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SkeletonAvatar({ className, size = "md", ...props }: React.HTMLAttributes<HTMLDivElement> & { size?: "xs" | "sm" | "md" | "lg" | "xl" }) {
|
||||
const sizeClasses = { xs: "size-6", sm: "size-8", md: "size-10", lg: "size-12", xl: "size-16" }
|
||||
return <Skeleton className={cn("rounded-full", sizeClasses[size], className)} {...props} />
|
||||
}
|
||||
|
||||
function SkeletonButton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <Skeleton className={cn("h-8 w-24 rounded-lg", className)} {...props} />
|
||||
}
|
||||
|
||||
function SkeletonTable({ className, rows = 5, columns = 4, ...props }: React.HTMLAttributes<HTMLDivElement> & { rows?: number; columns?: number }) {
|
||||
return (
|
||||
<div className={cn("space-y-3", className)} {...props}>
|
||||
<div className="flex gap-4">
|
||||
{Array.from({ length: columns }).map((_, i) => <Skeleton key={i} className="h-4 flex-1" />)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div key={rowIndex} className="flex gap-4">
|
||||
{Array.from({ length: columns }).map((_, colIndex) => <Skeleton key={colIndex} className="h-4 flex-1" />)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton, SkeletonAvatar, SkeletonButton, SkeletonCard, SkeletonTable, SkeletonText }
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: sparkline
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Sparkline(props: SparklineProps): JSX.Element"
|
||||
description: "Mini gráfico inline SVG puro (sin Recharts) con variantes line, area y bar. Para KPI cards y tablas."
|
||||
tags: [sparkline, chart, inline, svg, component, ui, visualization]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/sparkline.tsx"
|
||||
props:
|
||||
- name: data
|
||||
type: "number[]"
|
||||
required: true
|
||||
description: "Array de valores numéricos"
|
||||
- name: variant
|
||||
type: "'line' | 'area' | 'bar'"
|
||||
required: false
|
||||
description: "Tipo de visualización"
|
||||
- name: color
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Color del gráfico"
|
||||
- name: width
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Ancho en px (default 80)"
|
||||
- name: height
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Alto en px (default 24)"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [line, area, bar]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/sparkline.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Sparkline data={[10, 25, 15, 40, 30, 55]} variant="area" color="#22c55e" />
|
||||
<Sparkline data={[5, 3, 8, 1, 9]} variant="bar" width={60} height={20} />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
SVG puro — sin dependencia de Recharts. Ideal para inline en tablas y cards.
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../core/cn'
|
||||
|
||||
type SparklineVariant = 'line' | 'area' | 'bar'
|
||||
|
||||
interface SparklineProps extends React.SVGAttributes<SVGSVGElement> {
|
||||
data: number[]
|
||||
variant?: SparklineVariant
|
||||
color?: string
|
||||
width?: number
|
||||
height?: number
|
||||
strokeWidth?: number
|
||||
showLastPoint?: boolean
|
||||
}
|
||||
|
||||
function getPath(data: number[], width: number, height: number, padding: number = 2) {
|
||||
if (data.length === 0) return { linePath: '', areaPath: '' }
|
||||
const min = Math.min(...data)
|
||||
const max = Math.max(...data)
|
||||
const range = max - min || 1
|
||||
const ew = width - padding * 2
|
||||
const eh = height - padding * 2
|
||||
const points = data.map((value, index) => ({
|
||||
x: padding + (index / (data.length - 1)) * ew,
|
||||
y: padding + eh - ((value - min) / range) * eh,
|
||||
}))
|
||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
||||
const areaPath = `${linePath} L ${points[points.length - 1].x} ${height - padding} L ${padding} ${height - padding} Z`
|
||||
return { linePath, areaPath }
|
||||
}
|
||||
|
||||
const Sparkline = React.forwardRef<SVGSVGElement, SparklineProps>(
|
||||
({ data, variant = 'line', color = 'currentColor', width = 80, height = 24, strokeWidth = 1.5, showLastPoint = true, className, ...props }, ref) => {
|
||||
if (data.length === 0) return <svg ref={ref} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('text-primary', className)} {...props} />
|
||||
|
||||
if (variant === 'bar') {
|
||||
const min = Math.min(...data, 0)
|
||||
const max = Math.max(...data)
|
||||
const range = max - min || 1
|
||||
const p = 2
|
||||
const eh = height - p * 2
|
||||
const bw = (width - p * 2) / data.length - 1
|
||||
return (
|
||||
<svg ref={ref} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('text-primary', className)} {...props}>
|
||||
{data.map((value, index) => {
|
||||
const bh = ((value - min) / range) * eh
|
||||
const x = p + index * ((width - p * 2) / data.length) + 0.5
|
||||
const y = p + eh - bh
|
||||
return <rect key={index} x={x} y={y} width={Math.max(bw, 1)} height={Math.max(bh, 1)} fill={color} opacity={0.8} />
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const { linePath, areaPath } = getPath(data, width, height)
|
||||
const lastPoint = {
|
||||
x: width - 2,
|
||||
y: 2 + (height - 4) - ((data[data.length - 1] - Math.min(...data)) / (Math.max(...data) - Math.min(...data) || 1)) * (height - 4)
|
||||
}
|
||||
|
||||
return (
|
||||
<svg ref={ref} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('text-primary', className)} {...props}>
|
||||
{variant === 'area' && <path d={areaPath} fill={color} opacity={0.2} />}
|
||||
<path d={linePath} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
|
||||
{showLastPoint && <circle cx={lastPoint.x} cy={lastPoint.y} r={2.5} fill={color} />}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
)
|
||||
Sparkline.displayName = 'Sparkline'
|
||||
|
||||
export { Sparkline, type SparklineProps, type SparklineVariant }
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: tabs
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Tabs(props: TabsRootProps): JSX.Element"
|
||||
description: "Sistema de tabs con orientación horizontal/vertical, variantes default y line, y soporte para iconos. Base-UI primitive."
|
||||
tags: [tabs, navigation, component, ui, interactive]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react", class-variance-authority]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/tabs.tsx"
|
||||
props:
|
||||
- name: orientation
|
||||
type: "'horizontal' | 'vertical'"
|
||||
required: false
|
||||
description: "Orientación de los tabs"
|
||||
- name: variant
|
||||
type: "'default' | 'line'"
|
||||
required: false
|
||||
description: "Estilo visual de la lista"
|
||||
emits: [onValueChange]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default, line]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/tabs.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="security">Security</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general">...</TabsContent>
|
||||
<TabsContent value="security">...</TabsContent>
|
||||
</Tabs>
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root data-slot="tabs" data-orientation={orientation} className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: { default: "bg-muted", line: "gap-1 bg-transparent" },
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({ className, variant = "default", ...props }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return <TabsPrimitive.List data-slot="tabs-list" data-variant={variant} className={cn(tabsListVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return <TabsPrimitive.Panel data-slot="tabs-content" className={cn("flex-1 text-sm outline-none", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: theme_provider
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "ThemeProvider(props: { children: ReactNode; themes: Record<string, Theme>; defaultTheme?: string }): JSX.Element"
|
||||
description: "Provider de tema React con context, persistencia en localStorage, detección de preferencia del sistema y hook useTheme."
|
||||
tags: [theme, provider, context, hook, component, ui]
|
||||
uses_functions: [apply_theme_typescript_ui]
|
||||
uses_types: [ThemeConfig_typescript_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/theme_provider.tsx"
|
||||
props:
|
||||
- name: themes
|
||||
type: "Record<string, Theme>"
|
||||
required: true
|
||||
description: "Mapa de temas disponibles"
|
||||
- name: defaultTheme
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Nombre del tema por defecto"
|
||||
- name: children
|
||||
type: "ReactNode"
|
||||
required: true
|
||||
description: "Contenido de la app"
|
||||
emits: []
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/hooks/use-theme.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider, useTheme } from './theme_provider'
|
||||
|
||||
<ThemeProvider themes={allThemes} defaultTheme="dark">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
|
||||
// Dentro de un componente:
|
||||
function ThemeSwitcher() {
|
||||
const { themeName, setTheme, themes } = useTheme()
|
||||
return (
|
||||
<select value={themeName} onChange={(e) => setTheme(e.target.value)}>
|
||||
{Object.values(themes).map(t => <option key={t.name} value={t.name}>{t.label}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Detecta prefers-color-scheme automáticamente. Persiste elección en localStorage. Exporta ThemeProvider (componente) y useTheme (hook).
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
interface ThemeColors {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
interface Theme {
|
||||
name: string
|
||||
label: string
|
||||
colors: ThemeColors
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
themeName: string
|
||||
setTheme: (name: string) => void
|
||||
themes: Record<string, Theme>
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'frontend-library-theme'
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
function applyThemeColors(theme: Theme) {
|
||||
const root = document.documentElement
|
||||
const cssVarMap: Record<string, string> = {
|
||||
background: '--background', foreground: '--foreground',
|
||||
card: '--card', cardForeground: '--card-foreground',
|
||||
popover: '--popover', popoverForeground: '--popover-foreground',
|
||||
primary: '--primary', primaryForeground: '--primary-foreground',
|
||||
secondary: '--secondary', secondaryForeground: '--secondary-foreground',
|
||||
muted: '--muted', mutedForeground: '--muted-foreground',
|
||||
accent: '--accent', accentForeground: '--accent-foreground',
|
||||
destructive: '--destructive', destructiveForeground: '--destructive-foreground',
|
||||
success: '--success', successForeground: '--success-foreground',
|
||||
warning: '--warning', warningForeground: '--warning-foreground',
|
||||
info: '--info', infoForeground: '--info-foreground',
|
||||
surface: '--surface', surfaceHover: '--surface-hover', overlay: '--overlay',
|
||||
border: '--border', input: '--input', ring: '--ring',
|
||||
chart1: '--chart-1', chart2: '--chart-2', chart3: '--chart-3', chart4: '--chart-4', chart5: '--chart-5',
|
||||
sidebar: '--sidebar', sidebarForeground: '--sidebar-foreground',
|
||||
sidebarPrimary: '--sidebar-primary', sidebarPrimaryForeground: '--sidebar-primary-foreground',
|
||||
sidebarAccent: '--sidebar-accent', sidebarAccentForeground: '--sidebar-accent-foreground',
|
||||
sidebarBorder: '--sidebar-border', sidebarRing: '--sidebar-ring',
|
||||
}
|
||||
Object.entries(cssVarMap).forEach(([key, cssVar]) => {
|
||||
if (theme.colors[key]) root.style.setProperty(cssVar, theme.colors[key])
|
||||
})
|
||||
if (theme.name === 'dark' || theme.name === 'midnight' || theme.name === 'sunset') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode
|
||||
themes: Record<string, Theme>
|
||||
defaultTheme?: string
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children, themes, defaultTheme: initialTheme }: ThemeProviderProps) {
|
||||
const [themeName, setThemeName] = useState<string>(() => {
|
||||
if (initialTheme) return initialTheme
|
||||
if (typeof window === 'undefined') return 'default'
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored && themes[stored]) return stored
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark'
|
||||
return 'default'
|
||||
})
|
||||
|
||||
const theme = themes[themeName] ?? themes['default'] ?? Object.values(themes)[0]
|
||||
|
||||
const setTheme = useCallback((name: string) => {
|
||||
if (themes[name]) {
|
||||
setThemeName(name)
|
||||
localStorage.setItem(STORAGE_KEY, name)
|
||||
}
|
||||
}, [themes])
|
||||
|
||||
useEffect(() => { applyThemeColors(theme) }, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handle = (e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem(STORAGE_KEY)) setThemeName(e.matches ? 'dark' : 'default')
|
||||
}
|
||||
mq.addEventListener('change', handle)
|
||||
return () => mq.removeEventListener('change', handle)
|
||||
}, [])
|
||||
|
||||
return <ThemeContext.Provider value={{ theme, themeName, setTheme, themes }}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export { ThemeContext }
|
||||
export type { ThemeContextValue, ThemeProviderProps, Theme, ThemeColors }
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: tooltip
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Tooltip(props: TooltipRootProps): JSX.Element"
|
||||
description: "Tooltip accesible con animaciones, posicionamiento automático y arrow. Base-UI primitive con delay configurable."
|
||||
tags: [tooltip, overlay, component, ui, help]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react", react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/tooltip.tsx"
|
||||
props:
|
||||
- name: delayDuration
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Delay en ms antes de mostrar el tooltip (default 300)"
|
||||
emits: []
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/tooltip.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>Hover me</TooltipTrigger>
|
||||
<TooltipContent>Tooltip text</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Exporta 5 subcomponentes: TooltipProvider, Tooltip, TooltipTrigger, TooltipPortal, TooltipContent.
|
||||
TooltipProvider gestiona el delay global — envolver la app o sección con un único Provider.
|
||||
TooltipContent incluye Arrow con fill-primary automático.
|
||||
Depende de @base-ui/react — asegurarse de que está en package.json del frontend.
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
function TooltipProvider({ delayDuration = 300, ...props }: { delayDuration?: number; children: React.ReactNode }) {
|
||||
return <TooltipPrimitive.Provider delay={delayDuration} {...props} />
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipPortal({ ...props }: TooltipPrimitive.Portal.Props) {
|
||||
return <TooltipPrimitive.Portal data-slot="tooltip-portal" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({ className, sideOffset = 4, ...props }: TooltipPrimitive.Positioner.Props) {
|
||||
return (
|
||||
<TooltipPortal>
|
||||
<TooltipPrimitive.Positioner
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<TooltipPrimitive.Popup>{props.children}</TooltipPrimitive.Popup>
|
||||
<TooltipPrimitive.Arrow className="fill-primary" />
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPortal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger }
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: use_animated_canvas
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useAnimatedCanvas(options: AnimatedCanvasOptions): AnimatedCanvasResult"
|
||||
description: "Hook React para canvas animado a N fps via requestAnimationFrame. Maneja DPR, resize, throttling, y contador de FPS real."
|
||||
tags: [canvas, animation, fps, requestAnimationFrame, hook, realtime, component, ui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/use_animated_canvas.tsx"
|
||||
props:
|
||||
- name: fps
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Target FPS (default 60)"
|
||||
- name: draw
|
||||
type: "(ctx: CanvasRenderingContext2D, width: number, height: number, frameCount: number) => void"
|
||||
required: true
|
||||
description: "Callback de renderizado"
|
||||
emits: []
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
const { canvasRef, renderFpsRef } = useAnimatedCanvas({
|
||||
fps: 100,
|
||||
draw: (ctx, w, h) => {
|
||||
// Dibujar lo que sea a 100fps
|
||||
ctx.fillStyle = '#3b82f6'
|
||||
ctx.fillRect(0, 0, w * Math.random(), h)
|
||||
},
|
||||
})
|
||||
|
||||
return <canvas ref={canvasRef} style={{ width: '100%', height: 300 }} />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- DPR automático: escala el canvas al devicePixelRatio de la pantalla
|
||||
- Resize automático: detecta cambios de tamaño via getBoundingClientRect
|
||||
- Throttle configurable: rAF corre a ~144fps nativo, el hook filtra a N fps
|
||||
- FPS real: `renderFpsRef.current` tiene los FPS medidos (no el target)
|
||||
- drawRef pattern: actualiza el callback sin re-crear el loop de animación
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export interface AnimatedCanvasOptions {
|
||||
/** Target FPS (default 60). El throttle real es 1000/fps ms. */
|
||||
fps?: number
|
||||
/** Callback de dibujo. Recibe el canvas 2d context y dimensiones. */
|
||||
draw: (ctx: CanvasRenderingContext2D, width: number, height: number, frameCount: number) => void
|
||||
}
|
||||
|
||||
export interface AnimatedCanvasResult {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>
|
||||
/** FPS real de renderizado (actualizado cada segundo) */
|
||||
renderFpsRef: React.RefObject<number>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para renderizar un canvas a N fps usando requestAnimationFrame.
|
||||
* Maneja DPR, resize, y throttling automático.
|
||||
*/
|
||||
export function useAnimatedCanvas(options: AnimatedCanvasOptions): AnimatedCanvasResult {
|
||||
const { fps = 60, draw } = options
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const renderFpsRef = useRef(0)
|
||||
const rafRef = useRef(0)
|
||||
const lastDrawRef = useRef(0)
|
||||
const frameCountRef = useRef(0)
|
||||
const fpsTimerRef = useRef(0)
|
||||
const drawRef = useRef(draw)
|
||||
|
||||
// Keep draw ref updated without re-subscribing the effect
|
||||
drawRef.current = draw
|
||||
|
||||
useEffect(() => {
|
||||
const interval = 1000 / fps
|
||||
|
||||
const loop = (now: number) => {
|
||||
rafRef.current = requestAnimationFrame(loop)
|
||||
|
||||
if (now - lastDrawRef.current < interval) return
|
||||
lastDrawRef.current = now
|
||||
|
||||
// FPS counting
|
||||
frameCountRef.current++
|
||||
if (now - fpsTimerRef.current >= 1000) {
|
||||
renderFpsRef.current = frameCountRef.current
|
||||
frameCountRef.current = 0
|
||||
fpsTimerRef.current = now
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
const targetW = Math.floor(w * dpr)
|
||||
const targetH = Math.floor(h * dpr)
|
||||
|
||||
if (canvas.width !== targetW || canvas.height !== targetH) {
|
||||
canvas.width = targetW
|
||||
canvas.height = targetH
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
drawRef.current(ctx, w, h, frameCountRef.current)
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(loop)
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [fps])
|
||||
|
||||
return { canvasRef, renderFpsRef }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: use_wails_event
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useWailsEvent<T>(opts: UseWailsEventOptions<T>): UseWailsEventResult<T>"
|
||||
description: "Hook para suscripción a eventos Go→TS y emisión TS→Go via Wails runtime. Soporta once, maxCallbacks, emit bidireccional."
|
||||
tags: [wails, event, hook, ipc, realtime, component, ui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/use_wails_event.tsx"
|
||||
props:
|
||||
- name: eventName
|
||||
type: "string"
|
||||
required: true
|
||||
description: "Nombre del evento Wails"
|
||||
- name: onEvent
|
||||
type: "(data: T) => void"
|
||||
required: false
|
||||
description: "Callback al recibir evento"
|
||||
- name: once
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Solo escuchar una vez"
|
||||
- name: enabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Habilitar suscripción"
|
||||
emits: [onEvent]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/lib/wails/use-wails-event.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
// Escuchar eventos de Go
|
||||
const { lastData, eventCount, emit } = useWailsEvent<PriceUpdate>({
|
||||
eventName: 'price:update',
|
||||
onEvent: (price) => updateChart(price),
|
||||
})
|
||||
|
||||
// Emitir de TS a Go
|
||||
emit({ symbol: 'BTC', action: 'subscribe' })
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Exporta también `useWailsEmit()` para emit sin suscripción. Usa `window.runtime.EventsOn/Off/Emit` del Wails runtime.
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// Types for Wails runtime (will be available when running in Wails)
|
||||
declare global {
|
||||
interface Window {
|
||||
runtime?: {
|
||||
EventsOn: (eventName: string, callback: (...args: unknown[]) => void) => () => void
|
||||
EventsOff: (eventName: string, ...additionalEventNames: string[]) => void
|
||||
EventsOnce: (eventName: string, callback: (...args: unknown[]) => void) => () => void
|
||||
EventsOnMultiple: (eventName: string, callback: (...args: unknown[]) => void, maxCallbacks: number) => () => void
|
||||
EventsEmit: (eventName: string, ...data: unknown[]) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseWailsEventOptions<T> {
|
||||
/** Nombre del evento a escuchar */
|
||||
eventName: string
|
||||
/** Callback cuando llega el evento */
|
||||
onEvent?: (data: T) => void
|
||||
/** Si está habilitado */
|
||||
enabled?: boolean
|
||||
/** Solo escuchar una vez */
|
||||
once?: boolean
|
||||
/** Número máximo de veces a escuchar */
|
||||
maxCallbacks?: number
|
||||
}
|
||||
|
||||
export interface UseWailsEventResult<T> {
|
||||
/** Último dato recibido */
|
||||
lastData: T | undefined
|
||||
/** Número de eventos recibidos */
|
||||
eventCount: number
|
||||
/** Emitir un evento */
|
||||
emit: (data?: T) => void
|
||||
/** Resetear estado */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para suscribirse a eventos de Wails
|
||||
*/
|
||||
export function useWailsEvent<T = unknown>({
|
||||
eventName,
|
||||
onEvent,
|
||||
enabled = true,
|
||||
once = false,
|
||||
maxCallbacks,
|
||||
}: UseWailsEventOptions<T>): UseWailsEventResult<T> {
|
||||
const [lastData, setLastData] = useState<T | undefined>(undefined)
|
||||
const [eventCount, setEventCount] = useState(0)
|
||||
const callbackRef = useRef(onEvent)
|
||||
|
||||
// Keep callback ref updated
|
||||
useEffect(() => {
|
||||
callbackRef.current = onEvent
|
||||
}, [onEvent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !window.runtime) return
|
||||
|
||||
const handleEvent = (...args: unknown[]) => {
|
||||
const data = args[0] as T
|
||||
setLastData(data)
|
||||
setEventCount((c) => c + 1)
|
||||
callbackRef.current?.(data)
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | undefined
|
||||
|
||||
if (once) {
|
||||
unsubscribe = window.runtime.EventsOnce(eventName, handleEvent)
|
||||
} else if (maxCallbacks !== undefined) {
|
||||
unsubscribe = window.runtime.EventsOnMultiple(eventName, handleEvent, maxCallbacks)
|
||||
} else {
|
||||
unsubscribe = window.runtime.EventsOn(eventName, handleEvent)
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [eventName, enabled, once, maxCallbacks])
|
||||
|
||||
const emit = useCallback(
|
||||
(data?: T) => {
|
||||
if (window.runtime) {
|
||||
window.runtime.EventsEmit(eventName, data)
|
||||
}
|
||||
},
|
||||
[eventName]
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setLastData(undefined)
|
||||
setEventCount(0)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
lastData,
|
||||
eventCount,
|
||||
emit,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para emitir eventos a Wails (sin suscripción)
|
||||
*/
|
||||
export function useWailsEmit() {
|
||||
return useCallback((eventName: string, ...data: unknown[]) => {
|
||||
if (window.runtime) {
|
||||
window.runtime.EventsEmit(eventName, ...data)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: use_wails_mutation
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useWailsMutation<TData, TVariables>(opts: UseWailsMutationOptions<TData, TVariables>): UseWailsMutationResult<TData, TVariables>"
|
||||
description: "Hook para escrituras IPC Wails con optimistic updates, invalidación automática de queries, retry y callbacks completos."
|
||||
tags: [wails, mutation, hook, ipc, optimistic, component, ui]
|
||||
uses_functions: [wails_cache_typescript_core, wails_provider_typescript_ui]
|
||||
uses_types: [WailsIPC_typescript_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/use_wails_mutation.tsx"
|
||||
props:
|
||||
- name: mutationFn
|
||||
type: "(variables: TVariables) => Promise<TData>"
|
||||
required: true
|
||||
description: "Función que ejecuta la mutación via IPC"
|
||||
- name: invalidateQueries
|
||||
type: "string[][]"
|
||||
required: false
|
||||
description: "Query keys a invalidar en éxito"
|
||||
- name: onMutate
|
||||
type: "(variables: TVariables) => unknown"
|
||||
required: false
|
||||
description: "Optimistic update antes de la mutación"
|
||||
emits: [onSuccess, onError, onSettled]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/lib/wails/use-wails-mutation.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
const { mutate, isLoading } = useWailsMutation({
|
||||
mutationFn: (user: User) => CreateUser(user),
|
||||
invalidateQueries: [['users']],
|
||||
onMutate: (user) => {
|
||||
// Optimistic: añadir al cache antes de confirmar
|
||||
const prev = wailsCache.get<User[]>(['users', 'list'])
|
||||
wailsCache.set(['users', 'list'], [...(prev || []), user])
|
||||
return { prev }
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
// Rollback
|
||||
wailsCache.set(['users', 'list'], context.prev)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
`mutate()` es fire-and-forget, `mutateAsync()` retorna Promise. Estado: idle → loading → success/error.
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useWailsContext } from './wails_provider'
|
||||
import type { MutationOptions } from '../../types/ui/wails_ipc'
|
||||
|
||||
export interface UseWailsMutationOptions<TData, TVariables> extends MutationOptions {
|
||||
/** Función que ejecuta la mutación */
|
||||
mutationFn: (variables: TVariables) => Promise<TData>
|
||||
/** Callback antes de la mutación (optimistic update) */
|
||||
onMutate?: (variables: TVariables) => Promise<unknown> | unknown
|
||||
/** Callback en éxito */
|
||||
onSuccess?: (data: TData, variables: TVariables, context: unknown) => void
|
||||
/** Callback en error */
|
||||
onError?: (error: Error, variables: TVariables, context: unknown) => void
|
||||
/** Callback siempre (éxito o error) */
|
||||
onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables, context: unknown) => void
|
||||
/** Query keys a invalidar en éxito */
|
||||
invalidateQueries?: string[][]
|
||||
}
|
||||
|
||||
export interface UseWailsMutationResult<TData, TVariables> {
|
||||
/** Ejecutar la mutación */
|
||||
mutate: (variables: TVariables) => void
|
||||
/** Ejecutar la mutación (async) */
|
||||
mutateAsync: (variables: TVariables) => Promise<TData>
|
||||
/** Estado de carga */
|
||||
isLoading: boolean
|
||||
/** Datos del resultado */
|
||||
data: TData | undefined
|
||||
/** Error si ocurrió */
|
||||
error: Error | null
|
||||
/** Resetear estado */
|
||||
reset: () => void
|
||||
/** Si fue exitoso */
|
||||
isSuccess: boolean
|
||||
/** Si hubo error */
|
||||
isError: boolean
|
||||
/** Si está idle */
|
||||
isIdle: boolean
|
||||
}
|
||||
|
||||
export function useWailsMutation<TData, TVariables = void>({
|
||||
mutationFn,
|
||||
onMutate,
|
||||
onSuccess,
|
||||
onError,
|
||||
onSettled,
|
||||
invalidateQueries,
|
||||
retry = false,
|
||||
retryDelay = 1000,
|
||||
}: UseWailsMutationOptions<TData, TVariables>): UseWailsMutationResult<TData, TVariables> {
|
||||
const { cache } = useWailsContext()
|
||||
|
||||
const [state, setState] = useState<{
|
||||
status: 'idle' | 'loading' | 'success' | 'error'
|
||||
data: TData | undefined
|
||||
error: Error | null
|
||||
}>({
|
||||
status: 'idle',
|
||||
data: undefined,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const retryCountRef = useRef(0)
|
||||
|
||||
const executeMutation = useCallback(
|
||||
async (variables: TVariables): Promise<TData> => {
|
||||
setState((s) => ({ ...s, status: 'loading', error: null }))
|
||||
|
||||
let context: unknown
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
if (onMutate) {
|
||||
context = await onMutate(variables)
|
||||
}
|
||||
|
||||
const data = await mutationFn(variables)
|
||||
|
||||
// Reset retry count on success
|
||||
retryCountRef.current = 0
|
||||
|
||||
setState({
|
||||
status: 'success',
|
||||
data,
|
||||
error: null,
|
||||
})
|
||||
|
||||
// Invalidate queries
|
||||
if (invalidateQueries) {
|
||||
invalidateQueries.forEach((queryKey) => {
|
||||
cache.invalidate(queryKey)
|
||||
})
|
||||
}
|
||||
|
||||
onSuccess?.(data, variables, context)
|
||||
onSettled?.(data, null, variables, context)
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
const maxRetries = typeof retry === 'number' ? retry : retry ? 3 : 0
|
||||
|
||||
if (retryCountRef.current < maxRetries) {
|
||||
retryCountRef.current += 1
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay))
|
||||
return executeMutation(variables)
|
||||
}
|
||||
|
||||
setState((s) => ({
|
||||
...s,
|
||||
status: 'error',
|
||||
error: error as Error,
|
||||
}))
|
||||
|
||||
onError?.(error as Error, variables, context)
|
||||
onSettled?.(undefined, error as Error, variables, context)
|
||||
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[mutationFn, onMutate, onSuccess, onError, onSettled, invalidateQueries, cache, retry, retryDelay]
|
||||
)
|
||||
|
||||
const mutate = useCallback(
|
||||
(variables: TVariables) => {
|
||||
executeMutation(variables).catch(() => {})
|
||||
},
|
||||
[executeMutation]
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
status: 'idle',
|
||||
data: undefined,
|
||||
error: null,
|
||||
})
|
||||
retryCountRef.current = 0
|
||||
}, [])
|
||||
|
||||
return {
|
||||
mutate,
|
||||
mutateAsync: executeMutation,
|
||||
isLoading: state.status === 'loading',
|
||||
data: state.data,
|
||||
error: state.error,
|
||||
reset,
|
||||
isSuccess: state.status === 'success',
|
||||
isError: state.status === 'error',
|
||||
isIdle: state.status === 'idle',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: use_wails_query
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useWailsQuery<T>(opts: UseWailsQueryOptions<T>): UseWailsQueryResult<T>"
|
||||
description: "Hook React Query-like sobre IPC Wails. Cache automático, refetch por intervalo/foco, retry con backoff, invalidación."
|
||||
tags: [wails, query, hook, ipc, cache, component, ui]
|
||||
uses_functions: [wails_cache_typescript_core, wails_provider_typescript_ui]
|
||||
uses_types: [WailsIPC_typescript_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/use_wails_query.tsx"
|
||||
props:
|
||||
- name: queryKey
|
||||
type: "string[]"
|
||||
required: true
|
||||
description: "Key única para cache"
|
||||
- name: queryFn
|
||||
type: "() => Promise<T>"
|
||||
required: true
|
||||
description: "Función que llama al binding Wails"
|
||||
- name: enabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Habilitar auto-fetch (default true)"
|
||||
- name: refetchInterval
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Refetch cada N ms"
|
||||
- name: staleTime
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Tiempo antes de considerar datos stale"
|
||||
emits: [onSuccess, onError]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/lib/wails/use-wails-query.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
const { data, loading, refetch, invalidate } = useWailsQuery({
|
||||
queryKey: ['users', 'list'],
|
||||
queryFn: () => GetUsers(), // Wails binding
|
||||
refetchInterval: 30000, // Cada 30s
|
||||
staleTime: 5000, // Fresh por 5s
|
||||
onSuccess: (users) => console.log(`${users.length} users`),
|
||||
})
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
API inspirada en TanStack Query pero sobre IPC Wails (sin HTTP). Cache compartido via WailsProvider o singleton. Refetch automático en window focus.
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useWailsContext } from './wails_provider'
|
||||
import { DEFAULT_QUERY_OPTIONS, type QueryOptions, type QueryState } from '../../types/ui/wails_ipc'
|
||||
|
||||
export interface UseWailsQueryOptions<T> extends QueryOptions {
|
||||
/** Key única para identificar esta query en el cache */
|
||||
queryKey: string[]
|
||||
/** Función que ejecuta la llamada a Wails */
|
||||
queryFn: () => Promise<T>
|
||||
/** Callback en éxito */
|
||||
onSuccess?: (data: T) => void
|
||||
/** Callback en error */
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export interface UseWailsQueryResult<T> extends QueryState<T> {
|
||||
/** Re-ejecutar la query manualmente */
|
||||
refetch: () => Promise<T>
|
||||
/** Invalidar cache de esta query */
|
||||
invalidate: () => void
|
||||
}
|
||||
|
||||
export function useWailsQuery<T>({
|
||||
queryKey,
|
||||
queryFn,
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: UseWailsQueryOptions<T>): UseWailsQueryResult<T> {
|
||||
const { cache, defaultQueryOptions } = useWailsContext()
|
||||
|
||||
// Merge options with defaults
|
||||
const opts = {
|
||||
...DEFAULT_QUERY_OPTIONS,
|
||||
...defaultQueryOptions,
|
||||
...options,
|
||||
}
|
||||
|
||||
const {
|
||||
enabled,
|
||||
staleTime,
|
||||
refetchInterval,
|
||||
refetchOnFocus,
|
||||
retry,
|
||||
retryDelay,
|
||||
} = opts
|
||||
|
||||
const [state, setState] = useState<QueryState<T>>(() => ({
|
||||
data: cache.get<T>(queryKey),
|
||||
loading: !cache.has(queryKey) && enabled,
|
||||
error: null,
|
||||
isStale: cache.isStale(queryKey, staleTime),
|
||||
lastUpdated: cache.getTimestamp(queryKey),
|
||||
}))
|
||||
|
||||
const retryCountRef = useRef(0)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const fetch = useCallback(async (): Promise<T> => {
|
||||
if (!mountedRef.current) throw new Error('Component unmounted')
|
||||
|
||||
setState((s) => ({ ...s, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const data = await queryFn()
|
||||
|
||||
if (!mountedRef.current) throw new Error('Component unmounted')
|
||||
|
||||
cache.set(queryKey, data)
|
||||
retryCountRef.current = 0
|
||||
|
||||
setState({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
isStale: false,
|
||||
lastUpdated: new Date(),
|
||||
})
|
||||
|
||||
onSuccess?.(data)
|
||||
return data
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) throw error
|
||||
|
||||
const maxRetries = typeof retry === 'number' ? retry : retry ? 3 : 0
|
||||
|
||||
if (retryCountRef.current < maxRetries) {
|
||||
retryCountRef.current += 1
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay))
|
||||
return fetch()
|
||||
}
|
||||
|
||||
setState((s) => ({
|
||||
...s,
|
||||
loading: false,
|
||||
error: error as Error,
|
||||
}))
|
||||
|
||||
onError?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
}, [queryKey.join(':'), queryFn, cache, staleTime, retry, retryDelay, onSuccess, onError])
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
if (enabled && (!cache.has(queryKey) || cache.isStale(queryKey, staleTime))) {
|
||||
fetch().catch(() => {})
|
||||
}
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [enabled, queryKey.join(':'), staleTime])
|
||||
|
||||
// Refetch interval
|
||||
useEffect(() => {
|
||||
if (!refetchInterval || !enabled) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetch().catch(() => {})
|
||||
}, refetchInterval)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [refetchInterval, enabled, fetch])
|
||||
|
||||
// Refetch on window focus
|
||||
useEffect(() => {
|
||||
if (!refetchOnFocus || !enabled) return
|
||||
|
||||
const handleFocus = () => {
|
||||
if (cache.isStale(queryKey, staleTime)) {
|
||||
fetch().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [refetchOnFocus, enabled, queryKey.join(':'), staleTime, fetch])
|
||||
|
||||
// Subscribe to cache changes
|
||||
useEffect(() => {
|
||||
return cache.subscribe(queryKey, () => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
data: cache.get<T>(queryKey),
|
||||
isStale: cache.isStale(queryKey, staleTime),
|
||||
lastUpdated: cache.getTimestamp(queryKey),
|
||||
}))
|
||||
})
|
||||
}, [queryKey.join(':'), staleTime])
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
cache.invalidate(queryKey)
|
||||
}, [queryKey.join(':')])
|
||||
|
||||
return {
|
||||
...state,
|
||||
refetch: fetch,
|
||||
invalidate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: use_wails_stream
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useWailsStream<T>(opts: UseWailsStreamOptions<T>): UseWailsStreamResult<T>"
|
||||
description: "Hook para streaming de datos Go→TS con buffer configurable, auto-complete, transform y control start/stop. Incluye useWailsLogs."
|
||||
tags: [wails, stream, hook, ipc, realtime, buffer, component, ui]
|
||||
uses_functions: [use_wails_event_typescript_ui]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/use_wails_stream.tsx"
|
||||
props:
|
||||
- name: streamName
|
||||
type: "string"
|
||||
required: true
|
||||
description: "Nombre del evento de stream"
|
||||
- name: startFn
|
||||
type: "() => Promise<void>"
|
||||
required: false
|
||||
description: "Función Go para iniciar el stream"
|
||||
- name: stopFn
|
||||
type: "() => Promise<void>"
|
||||
required: false
|
||||
description: "Función Go para detener el stream"
|
||||
- name: bufferSize
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Tamaño máximo del buffer (default 1000)"
|
||||
- name: transform
|
||||
type: "(data: unknown) => T"
|
||||
required: false
|
||||
description: "Transformar datos entrantes"
|
||||
emits: [onData, onComplete, onError]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/lib/wails/use-wails-stream.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
// Stream de logs en tiempo real
|
||||
const { data: logs, isStreaming, start, stop, clear } = useWailsStream<string>({
|
||||
streamName: 'app:logs',
|
||||
startFn: () => StartLogStream(),
|
||||
stopFn: () => StopLogStream(),
|
||||
bufferSize: 500,
|
||||
autoStart: true,
|
||||
})
|
||||
|
||||
// Versión simplificada para logs
|
||||
const { data: logs } = useWailsLogs('deploy:logs')
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Protocolo de stream: Go emite chunks en `{streamName}`, completa con `{streamName}:complete`, errores con `{streamName}:error`. Buffer circular con tamaño configurable.
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useWailsEvent } from './use_wails_event'
|
||||
|
||||
export interface UseWailsStreamOptions<T> {
|
||||
/** Nombre del evento de stream */
|
||||
streamName: string
|
||||
/** Función para iniciar el stream en Go */
|
||||
startFn?: () => Promise<void>
|
||||
/** Función para detener el stream en Go */
|
||||
stopFn?: () => Promise<void>
|
||||
/** Transformar datos entrantes */
|
||||
transform?: (data: unknown) => T
|
||||
/** Callback por cada chunk */
|
||||
onData?: (data: T) => void
|
||||
/** Callback cuando el stream termina */
|
||||
onComplete?: () => void
|
||||
/** Callback en error */
|
||||
onError?: (error: Error) => void
|
||||
/** Tamaño máximo del buffer */
|
||||
bufferSize?: number
|
||||
/** Auto-iniciar el stream */
|
||||
autoStart?: boolean
|
||||
}
|
||||
|
||||
export interface UseWailsStreamResult<T> {
|
||||
/** Datos acumulados */
|
||||
data: T[]
|
||||
/** Último dato recibido */
|
||||
lastChunk: T | undefined
|
||||
/** Si está activo */
|
||||
isStreaming: boolean
|
||||
/** Si está cargando (iniciando) */
|
||||
isLoading: boolean
|
||||
/** Error si ocurrió */
|
||||
error: Error | null
|
||||
/** Iniciar stream */
|
||||
start: () => Promise<void>
|
||||
/** Detener stream */
|
||||
stop: () => Promise<void>
|
||||
/** Limpiar buffer */
|
||||
clear: () => void
|
||||
/** Número de chunks recibidos */
|
||||
chunkCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para manejar streams de datos desde Wails
|
||||
* Útil para datos en tiempo real como logs, métricas, etc.
|
||||
*/
|
||||
export function useWailsStream<T = unknown>({
|
||||
streamName,
|
||||
startFn,
|
||||
stopFn,
|
||||
transform,
|
||||
onData,
|
||||
onComplete,
|
||||
onError,
|
||||
bufferSize = 1000,
|
||||
autoStart = false,
|
||||
}: UseWailsStreamOptions<T>): UseWailsStreamResult<T> {
|
||||
const [data, setData] = useState<T[]>([])
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [chunkCount, setChunkCount] = useState(0)
|
||||
|
||||
const lastChunkRef = useRef<T | undefined>(undefined)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
// Listen for stream data
|
||||
useWailsEvent<unknown>({
|
||||
eventName: streamName,
|
||||
enabled: isStreaming,
|
||||
onEvent: (rawData) => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
const chunk = transform ? transform(rawData) : (rawData as T)
|
||||
lastChunkRef.current = chunk
|
||||
|
||||
setData((prev) => {
|
||||
const newData = [...prev, chunk]
|
||||
// Mantener buffer limitado
|
||||
if (newData.length > bufferSize) {
|
||||
return newData.slice(-bufferSize)
|
||||
}
|
||||
return newData
|
||||
})
|
||||
|
||||
setChunkCount((c) => c + 1)
|
||||
onData?.(chunk)
|
||||
},
|
||||
})
|
||||
|
||||
// Listen for stream complete
|
||||
useWailsEvent({
|
||||
eventName: `${streamName}:complete`,
|
||||
enabled: isStreaming,
|
||||
onEvent: () => {
|
||||
if (!mountedRef.current) return
|
||||
setIsStreaming(false)
|
||||
onComplete?.()
|
||||
},
|
||||
})
|
||||
|
||||
// Listen for stream error
|
||||
useWailsEvent<{ message: string }>({
|
||||
eventName: `${streamName}:error`,
|
||||
enabled: isStreaming,
|
||||
onEvent: (errorData) => {
|
||||
if (!mountedRef.current) return
|
||||
const err = new Error(errorData?.message || 'Stream error')
|
||||
setError(err)
|
||||
setIsStreaming(false)
|
||||
onError?.(err)
|
||||
},
|
||||
})
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (isStreaming || isLoading) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
if (startFn) {
|
||||
await startFn()
|
||||
}
|
||||
setIsStreaming(true)
|
||||
} catch (err) {
|
||||
const error = err as Error
|
||||
setError(error)
|
||||
onError?.(error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [isStreaming, isLoading, startFn, onError])
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (!isStreaming) return
|
||||
|
||||
try {
|
||||
if (stopFn) {
|
||||
await stopFn()
|
||||
}
|
||||
setIsStreaming(false)
|
||||
} catch (err) {
|
||||
const error = err as Error
|
||||
setError(error)
|
||||
onError?.(error)
|
||||
}
|
||||
}, [isStreaming, stopFn, onError])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setData([])
|
||||
setChunkCount(0)
|
||||
lastChunkRef.current = undefined
|
||||
}, [])
|
||||
|
||||
// Auto-start
|
||||
useEffect(() => {
|
||||
if (autoStart) {
|
||||
start()
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
data,
|
||||
lastChunk: lastChunkRef.current,
|
||||
isStreaming,
|
||||
isLoading,
|
||||
error,
|
||||
start,
|
||||
stop,
|
||||
clear,
|
||||
chunkCount,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook simplificado para logs en tiempo real
|
||||
*/
|
||||
export function useWailsLogs(eventName: string = 'logs') {
|
||||
return useWailsStream<string>({
|
||||
streamName: eventName,
|
||||
transform: (data) => (typeof data === 'string' ? data : JSON.stringify(data)),
|
||||
bufferSize: 500,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: wails_provider
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "WailsProvider(props: { children: ReactNode; cache?: WailsCache; defaultQueryOptions?: QueryOptions }): JSX.Element"
|
||||
description: "Provider React para IPC Wails con cache context, opciones default y fallback a singleton. Exporta useWailsContext y useWailsCache."
|
||||
tags: [wails, provider, context, ipc, component, ui]
|
||||
uses_functions: [wails_cache_typescript_core]
|
||||
uses_types: [WailsIPC_typescript_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/wails_provider.tsx"
|
||||
props:
|
||||
- name: cache
|
||||
type: "WailsCache"
|
||||
required: false
|
||||
description: "Cache custom (default: singleton global)"
|
||||
- name: defaultQueryOptions
|
||||
type: "Partial<QueryOptions>"
|
||||
required: false
|
||||
description: "Opciones default para todas las queries"
|
||||
- name: children
|
||||
type: "ReactNode"
|
||||
required: true
|
||||
description: "App content"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/lib/wails/wails-provider.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<WailsProvider defaultQueryOptions={{ staleTime: 5000, retry: 2 }}>
|
||||
<App />
|
||||
</WailsProvider>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Sin provider, los hooks hacen fallback al singleton `wailsCache`. El provider solo es necesario para cache custom o opciones globales.
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
||||
import { WailsCache, wailsCache } from '../core/wails_cache'
|
||||
import type { QueryOptions, MutationOptions } from '../../types/ui/wails_ipc'
|
||||
|
||||
interface WailsContextValue {
|
||||
cache: WailsCache
|
||||
defaultQueryOptions: Partial<QueryOptions>
|
||||
defaultMutationOptions: Partial<MutationOptions>
|
||||
}
|
||||
|
||||
const WailsContext = createContext<WailsContextValue | null>(null)
|
||||
|
||||
export interface WailsProviderProps {
|
||||
children: ReactNode
|
||||
/** Usar un cache custom en lugar del singleton */
|
||||
cache?: WailsCache
|
||||
/** Opciones por defecto para queries */
|
||||
defaultQueryOptions?: Partial<QueryOptions>
|
||||
/** Opciones por defecto para mutations */
|
||||
defaultMutationOptions?: Partial<MutationOptions>
|
||||
}
|
||||
|
||||
export function WailsProvider({
|
||||
children,
|
||||
cache,
|
||||
defaultQueryOptions = {},
|
||||
defaultMutationOptions = {},
|
||||
}: WailsProviderProps) {
|
||||
const value = useMemo<WailsContextValue>(
|
||||
() => ({
|
||||
cache: cache ?? wailsCache,
|
||||
defaultQueryOptions,
|
||||
defaultMutationOptions,
|
||||
}),
|
||||
[cache, defaultQueryOptions, defaultMutationOptions]
|
||||
)
|
||||
|
||||
return (
|
||||
<WailsContext.Provider value={value}>
|
||||
{children}
|
||||
</WailsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWailsContext(): WailsContextValue {
|
||||
const context = useContext(WailsContext)
|
||||
if (!context) {
|
||||
// Fallback to singleton cache if no provider
|
||||
return {
|
||||
cache: wailsCache,
|
||||
defaultQueryOptions: {},
|
||||
defaultMutationOptions: {},
|
||||
}
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function useWailsCache(): WailsCache {
|
||||
return useWailsContext().cache
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: ComponentVariants
|
||||
lang: typescript
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
interface ComponentBaseProps { className?: string; children?: React.ReactNode }
|
||||
type PropsWithVariants<V> = ComponentBaseProps & VariantProps<V>
|
||||
description: "Tipos base para componentes con variantes CVA. Props comunes y composición de variantes type-safe."
|
||||
tags: [component, variants, cva, props, base]
|
||||
uses_types: []
|
||||
file_path: "frontend/types/core/component_variants.ts"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/button.tsx"
|
||||
---
|
||||
|
||||
## Tipos exportados
|
||||
|
||||
- `VariantProps<V>` — re-exportado de `class-variance-authority`. Extrae el tipo de props de un `cva()` call.
|
||||
- `ComponentBaseProps` — props comunes a todos los componentes: `className` y `children` opcionales.
|
||||
- `PropsWithVariants<V>` — combinación de `ComponentBaseProps` con las variantes de un `cva()` concreto. Usar como base para las props de cualquier componente con variantes.
|
||||
|
||||
## Uso
|
||||
|
||||
```typescript
|
||||
import { cva } from "class-variance-authority"
|
||||
import { type PropsWithVariants } from "@/types/core/component_variants"
|
||||
|
||||
const buttonVariants = cva("base-classes", {
|
||||
variants: { size: { sm: "...", md: "..." } }
|
||||
})
|
||||
|
||||
type ButtonProps = PropsWithVariants<typeof buttonVariants>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Requiere `class-variance-authority` como dependencia del proyecto consumidor. `React.ReactNode` requiere que `react` esté en el scope — en proyectos con `@types/react` esto se resuelve automáticamente.
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
export type { VariantProps }
|
||||
|
||||
export interface ComponentBaseProps {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export type PropsWithVariants<V> = ComponentBaseProps & VariantProps<V>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user