feat: funciones frontend React/TS — componentes UI, hooks Wails, charts y tipos
Componentes React reutilizables: card, dialog, tabs, select, alert, badge, button, input, label, skeleton, tooltip, progress_bar, page_header, form_field, settings_page, crud_page, analytics_page, dashboard_layout. Charts: area, bar, line, sparkline, kpi_card, chart_container. Hooks Wails: use_wails_query, use_wails_mutation, use_wails_stream, use_wails_event, use_animated_canvas. Funciones core: cn, format_compact, chart_colors, get_series_color, wails_cache, theme_config_to_colors. Tipos: chart_series, wails_ipc, theme_config, component_variants. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: ChartSeries
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
interface Series { key: string; name: string; color?: string }
|
||||
interface ChartDataPoint { [key: string]: string | number }
|
||||
description: "Tipos base para series y datos de gráficos. Usados por todos los chart components."
|
||||
tags: [chart, series, data, visualization]
|
||||
uses_types: []
|
||||
file_path: "frontend/types/ui/chart_series.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"
|
||||
---
|
||||
|
||||
## Interfaces exportadas
|
||||
|
||||
- `Series` — describe una serie de datos: `key` (campo en el datapoint), `name` (label visible), `color` opcional (override del color del tema)
|
||||
- `ChartDataPoint` — mapa genérico `string -> string | number`, representa un punto de datos indexado por clave
|
||||
|
||||
## Notas
|
||||
|
||||
Tipos mínimos sin dependencias. `ChartDataPoint` usa index signature para permitir cualquier combinación de campos numéricos y categóricos en el mismo punto de datos.
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface Series {
|
||||
key: string
|
||||
name: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface ChartDataPoint {
|
||||
[key: string]: string | number
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: ThemeConfig
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
interface ThemeConfig { name: string; label: string; palette: ColorPalette; colors: SemanticColors; typography: Typography; spacing: Spacing; borders: Borders; shadows: Shadows; motion: Motion; breakpoints: Breakpoints; zIndex: ZIndex; icons: Icons }
|
||||
description: "Sistema completo de tokens de diseño: colores semánticos, tipografía, spacing, sombras, motion, breakpoints. Base del theming de Frontend Library."
|
||||
tags: [theme, design-tokens, colors, typography, ui]
|
||||
uses_types: []
|
||||
file_path: "frontend/types/ui/theme_config.ts"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/themes/types.ts"
|
||||
---
|
||||
|
||||
## Interfaces exportadas
|
||||
|
||||
- `ColorPalette` — colores primitivos (grays, brand, status, white/black)
|
||||
- `SemanticColors` — tokens semánticos (background, foreground, border, brand, status, surface, sidebar, chart)
|
||||
- `Typography` — fontFamily, fontSize, fontWeight, lineHeight, letterSpacing
|
||||
- `Spacing` — escala 0-96 basada en 4px
|
||||
- `Borders` — width y radius
|
||||
- `Shadows` — elevación xs-2xl, inner, glow
|
||||
- `Motion` — duration e easing
|
||||
- `Breakpoints` — xs-2xl
|
||||
- `ZIndex` — jerarquía de capas hide-max
|
||||
- `Icons` — size y strokeWidth para Phosphor Icons
|
||||
- `ThemeConfig` — composición completa de todos los tokens
|
||||
- `ThemeColors` — formato legacy para compatibilidad con shadcn/ui
|
||||
- `Theme` — nombre + label + ThemeColors
|
||||
- `ThemeName` — union type de los temas disponibles
|
||||
|
||||
## Helper
|
||||
|
||||
`themeConfigToColors(config: ThemeConfig): ThemeColors` — convierte un ThemeConfig al formato legacy ThemeColors que consume shadcn/ui.
|
||||
|
||||
## Notas
|
||||
|
||||
Copia directa de `frontend/src/themes/types.ts` de Frontend_Library. El archivo es autocontenido sin dependencias externas. Los colores usan formato OKLCH via variables CSS.
|
||||
@@ -0,0 +1,540 @@
|
||||
// ============================================================================
|
||||
// THEME SYSTEM TYPES
|
||||
// Sistema de temas completo para Frontend Library
|
||||
// ============================================================================
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 1. COLOR PALETTE (Primitivos)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ColorPalette {
|
||||
// Grays (escala neutral)
|
||||
gray50: string
|
||||
gray100: string
|
||||
gray200: string
|
||||
gray300: string
|
||||
gray400: string
|
||||
gray500: string
|
||||
gray600: string
|
||||
gray700: string
|
||||
gray800: string
|
||||
gray900: string
|
||||
gray950: string
|
||||
|
||||
// Brand colors (personalizables por tema)
|
||||
brand50: string
|
||||
brand100: string
|
||||
brand200: string
|
||||
brand300: string
|
||||
brand400: string
|
||||
brand500: string
|
||||
brand600: string
|
||||
brand700: string
|
||||
brand800: string
|
||||
brand900: string
|
||||
brand950: string
|
||||
|
||||
// Status colors
|
||||
red500: string
|
||||
red600: string
|
||||
green500: string
|
||||
green600: string
|
||||
yellow500: string
|
||||
yellow600: string
|
||||
blue500: string
|
||||
blue600: string
|
||||
|
||||
// Pure colors
|
||||
white: string
|
||||
black: string
|
||||
transparent: string
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 2. SEMANTIC COLORS (Tokens semánticos)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface SemanticColors {
|
||||
// Backgrounds
|
||||
background: {
|
||||
default: string
|
||||
subtle: string
|
||||
muted: string
|
||||
inverse: string
|
||||
}
|
||||
|
||||
// Foregrounds
|
||||
foreground: {
|
||||
default: string
|
||||
muted: string
|
||||
subtle: string
|
||||
disabled: string
|
||||
inverse: string
|
||||
}
|
||||
|
||||
// Borders
|
||||
border: {
|
||||
default: string
|
||||
muted: string
|
||||
strong: string
|
||||
focus: string
|
||||
}
|
||||
|
||||
// Brand
|
||||
brand: {
|
||||
primary: string
|
||||
primaryForeground: string
|
||||
secondary: string
|
||||
secondaryForeground: string
|
||||
accent: string
|
||||
accentForeground: string
|
||||
}
|
||||
|
||||
// Status
|
||||
status: {
|
||||
success: string
|
||||
successForeground: string
|
||||
successMuted: string
|
||||
warning: string
|
||||
warningForeground: string
|
||||
warningMuted: string
|
||||
error: string
|
||||
errorForeground: string
|
||||
errorMuted: string
|
||||
info: string
|
||||
infoForeground: string
|
||||
infoMuted: string
|
||||
}
|
||||
|
||||
// Surfaces (jerarquía de capas)
|
||||
surface: {
|
||||
base: string // Fondo principal
|
||||
raised: string // Cards, panels
|
||||
overlay: string // Modals, dropdowns
|
||||
sunken: string // Inputs, wells
|
||||
}
|
||||
|
||||
// Sidebar (panel lateral oscuro)
|
||||
sidebar: {
|
||||
background: string
|
||||
foreground: string
|
||||
muted: string
|
||||
accent: string
|
||||
accentForeground: string
|
||||
border: string
|
||||
ring: string
|
||||
}
|
||||
|
||||
// Charts
|
||||
chart: {
|
||||
1: string
|
||||
2: string
|
||||
3: string
|
||||
4: string
|
||||
5: string
|
||||
6: string
|
||||
7: string
|
||||
8: string
|
||||
}
|
||||
|
||||
// Interactive
|
||||
ring: string
|
||||
input: string
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 3. TYPOGRAPHY
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface Typography {
|
||||
fontFamily: {
|
||||
display: string // Títulos destacados
|
||||
heading: string // Encabezados
|
||||
body: string // Texto general
|
||||
mono: string // Código
|
||||
}
|
||||
|
||||
fontSize: {
|
||||
xs: string // 12px
|
||||
sm: string // 14px
|
||||
base: string // 16px
|
||||
lg: string // 18px
|
||||
xl: string // 20px
|
||||
'2xl': string // 24px
|
||||
'3xl': string // 30px
|
||||
'4xl': string // 36px
|
||||
'5xl': string // 48px
|
||||
'6xl': string // 60px
|
||||
}
|
||||
|
||||
fontWeight: {
|
||||
light: number
|
||||
normal: number
|
||||
medium: number
|
||||
semibold: number
|
||||
bold: number
|
||||
extrabold: number
|
||||
}
|
||||
|
||||
lineHeight: {
|
||||
none: number
|
||||
tight: number
|
||||
snug: number
|
||||
normal: number
|
||||
relaxed: number
|
||||
loose: number
|
||||
}
|
||||
|
||||
letterSpacing: {
|
||||
tighter: string
|
||||
tight: string
|
||||
normal: string
|
||||
wide: string
|
||||
wider: string
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 4. SPACING
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface Spacing {
|
||||
0: string
|
||||
px: string
|
||||
0.5: string
|
||||
1: string
|
||||
1.5: string
|
||||
2: string
|
||||
2.5: string
|
||||
3: string
|
||||
3.5: string
|
||||
4: string
|
||||
5: string
|
||||
6: string
|
||||
7: string
|
||||
8: string
|
||||
9: string
|
||||
10: string
|
||||
11: string
|
||||
12: string
|
||||
14: string
|
||||
16: string
|
||||
20: string
|
||||
24: string
|
||||
28: string
|
||||
32: string
|
||||
36: string
|
||||
40: string
|
||||
44: string
|
||||
48: string
|
||||
52: string
|
||||
56: string
|
||||
60: string
|
||||
64: string
|
||||
72: string
|
||||
80: string
|
||||
96: string
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 5. BORDERS
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface Borders {
|
||||
width: {
|
||||
none: string
|
||||
thin: string
|
||||
default: string
|
||||
medium: string
|
||||
thick: string
|
||||
}
|
||||
|
||||
radius: {
|
||||
none: string
|
||||
sm: string
|
||||
default: string
|
||||
md: string
|
||||
lg: string
|
||||
xl: string
|
||||
'2xl': string
|
||||
'3xl': string
|
||||
full: string
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 6. SHADOWS (Elevation)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface Shadows {
|
||||
none: string
|
||||
xs: string
|
||||
sm: string
|
||||
default: string
|
||||
md: string
|
||||
lg: string
|
||||
xl: string
|
||||
'2xl': string
|
||||
inner: string
|
||||
// Colored shadows for glow effects
|
||||
glow: {
|
||||
sm: string
|
||||
md: string
|
||||
lg: string
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 7. MOTION / ANIMATION
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface Motion {
|
||||
duration: {
|
||||
instant: string // 0ms
|
||||
fast: string // 100ms
|
||||
normal: string // 200ms
|
||||
slow: string // 300ms
|
||||
slower: string // 500ms
|
||||
slowest: string // 1000ms
|
||||
}
|
||||
|
||||
easing: {
|
||||
linear: string
|
||||
default: string
|
||||
in: string
|
||||
out: string
|
||||
inOut: string
|
||||
spring: string
|
||||
bounce: string
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 8. BREAKPOINTS
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface Breakpoints {
|
||||
xs: string
|
||||
sm: string
|
||||
md: string
|
||||
lg: string
|
||||
xl: string
|
||||
'2xl': string
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 9. Z-INDEX
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ZIndex {
|
||||
hide: number
|
||||
base: number
|
||||
raised: number
|
||||
dropdown: number
|
||||
sticky: number
|
||||
overlay: number
|
||||
modal: number
|
||||
popover: number
|
||||
toast: number
|
||||
tooltip: number
|
||||
max: number
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 10. ICONS
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface Icons {
|
||||
size: {
|
||||
xs: string // 12px
|
||||
sm: string // 16px
|
||||
default: string // 20px
|
||||
md: string // 24px
|
||||
lg: string // 32px
|
||||
xl: string // 40px
|
||||
'2xl': string // 48px
|
||||
}
|
||||
|
||||
strokeWidth: {
|
||||
thin: number
|
||||
light: number
|
||||
regular: number
|
||||
bold: number
|
||||
fill: number
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// THEME CONFIGURATION (Complete)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ThemeConfig {
|
||||
// Metadata
|
||||
name: string
|
||||
label: string
|
||||
description?: string
|
||||
|
||||
// Color system
|
||||
palette: ColorPalette
|
||||
colors: SemanticColors
|
||||
|
||||
// Typography
|
||||
typography: Typography
|
||||
|
||||
// Layout
|
||||
spacing: Spacing
|
||||
borders: Borders
|
||||
shadows: Shadows
|
||||
|
||||
// Interaction
|
||||
motion: Motion
|
||||
|
||||
// Responsive
|
||||
breakpoints: Breakpoints
|
||||
zIndex: ZIndex
|
||||
|
||||
// Icons
|
||||
icons: Icons
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// LEGACY SUPPORT - ThemeColors (for backward compatibility)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ThemeColors {
|
||||
// Base
|
||||
background: string
|
||||
foreground: string
|
||||
|
||||
// Cards & Popovers
|
||||
card: string
|
||||
cardForeground: string
|
||||
popover: string
|
||||
popoverForeground: string
|
||||
|
||||
// Primary & Secondary
|
||||
primary: string
|
||||
primaryForeground: string
|
||||
secondary: string
|
||||
secondaryForeground: string
|
||||
|
||||
// Muted & Accent
|
||||
muted: string
|
||||
mutedForeground: string
|
||||
accent: string
|
||||
accentForeground: string
|
||||
|
||||
// Semantic colors
|
||||
destructive: string
|
||||
destructiveForeground: string
|
||||
success: string
|
||||
successForeground: string
|
||||
warning: string
|
||||
warningForeground: string
|
||||
info: string
|
||||
infoForeground: string
|
||||
|
||||
// Surfaces
|
||||
surface: string
|
||||
surfaceHover: string
|
||||
overlay: string
|
||||
|
||||
// Form elements
|
||||
border: string
|
||||
input: string
|
||||
ring: string
|
||||
|
||||
// Charts
|
||||
chart1: string
|
||||
chart2: string
|
||||
chart3: string
|
||||
chart4: string
|
||||
chart5: string
|
||||
|
||||
// Sidebar
|
||||
sidebar: string
|
||||
sidebarForeground: string
|
||||
sidebarPrimary: string
|
||||
sidebarPrimaryForeground: string
|
||||
sidebarAccent: string
|
||||
sidebarAccentForeground: string
|
||||
sidebarBorder: string
|
||||
sidebarRing: string
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
name: string
|
||||
label: string
|
||||
colors: ThemeColors
|
||||
}
|
||||
|
||||
export type ThemeName = 'default' | 'dark' | 'blue' | 'green' | 'purple' | 'orange' | 'rose' | 'cyan' | 'sunset' | 'midnight'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// HELPER: Convert ThemeConfig to legacy ThemeColors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export function themeConfigToColors(config: ThemeConfig): ThemeColors {
|
||||
const { colors } = config
|
||||
|
||||
return {
|
||||
// Base
|
||||
background: colors.background.default,
|
||||
foreground: colors.foreground.default,
|
||||
|
||||
// Cards & Popovers
|
||||
card: colors.surface.raised,
|
||||
cardForeground: colors.foreground.default,
|
||||
popover: colors.surface.overlay,
|
||||
popoverForeground: colors.foreground.default,
|
||||
|
||||
// Primary & Secondary
|
||||
primary: colors.brand.primary,
|
||||
primaryForeground: colors.brand.primaryForeground,
|
||||
secondary: colors.brand.secondary,
|
||||
secondaryForeground: colors.brand.secondaryForeground,
|
||||
|
||||
// Muted & Accent
|
||||
muted: colors.background.muted,
|
||||
mutedForeground: colors.foreground.muted,
|
||||
accent: colors.brand.accent,
|
||||
accentForeground: colors.brand.accentForeground,
|
||||
|
||||
// Semantic colors
|
||||
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,
|
||||
|
||||
// Surfaces
|
||||
surface: colors.surface.raised,
|
||||
surfaceHover: colors.background.subtle,
|
||||
overlay: colors.surface.overlay,
|
||||
|
||||
// Form elements
|
||||
border: colors.border.default,
|
||||
input: colors.border.default,
|
||||
ring: colors.ring,
|
||||
|
||||
// Charts
|
||||
chart1: colors.chart[1],
|
||||
chart2: colors.chart[2],
|
||||
chart3: colors.chart[3],
|
||||
chart4: colors.chart[4],
|
||||
chart5: colors.chart[5],
|
||||
|
||||
// Sidebar
|
||||
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,22 @@
|
||||
---
|
||||
name: WailsIPC
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
interface QueryState<T> { data: T | null; loading: boolean; error: Error | null; isStale: boolean; lastUpdated: Date | null }
|
||||
interface QueryOptions { enabled?: boolean; refetchInterval?: number; refetchOnFocus?: boolean; staleTime?: number; cacheTime?: number; retry?: number | boolean; retryDelay?: number }
|
||||
interface MutationOptions { retry?: number | boolean; retryDelay?: number }
|
||||
description: "Tipos base para el sistema IPC de Wails: QueryState, QueryOptions, MutationOptions, WailsEvent, defaults."
|
||||
tags: [wails, ipc, types, query, mutation]
|
||||
uses_types: []
|
||||
file_path: "frontend/types/ui/wails_ipc.ts"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/lib/wails/types.ts"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
Incluye constantes DEFAULT_QUERY_OPTIONS y DEFAULT_MUTATION_OPTIONS con valores sensibles para IPC (staleTime: 0, cacheTime: 5min, retry: 3).
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Estado de una query */
|
||||
export interface QueryState<T> {
|
||||
data: T | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
isStale: boolean
|
||||
lastUpdated: Date | null
|
||||
}
|
||||
|
||||
/** Opciones comunes para queries */
|
||||
export interface QueryOptions {
|
||||
/** Ejecutar automáticamente (default: true) */
|
||||
enabled?: boolean
|
||||
/** Auto-refetch cada N ms */
|
||||
refetchInterval?: number
|
||||
/** Refetch cuando la ventana gana foco */
|
||||
refetchOnFocus?: boolean
|
||||
/** Tiempo en ms antes de considerar datos stale (default: 0) */
|
||||
staleTime?: number
|
||||
/** Tiempo en ms en cache después de unmount (default: 5 min) */
|
||||
cacheTime?: number
|
||||
/** Número de reintentos en error (default: 3) */
|
||||
retry?: number | boolean
|
||||
/** Delay entre reintentos en ms */
|
||||
retryDelay?: number
|
||||
}
|
||||
|
||||
/** Opciones para mutations */
|
||||
export interface MutationOptions {
|
||||
/** Reintentos en error */
|
||||
retry?: number | boolean
|
||||
/** Delay entre reintentos */
|
||||
retryDelay?: number
|
||||
}
|
||||
|
||||
/** Resultado de un evento Wails */
|
||||
export interface WailsEvent<T> {
|
||||
name: string
|
||||
data: T
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
/** Estado de una mutation */
|
||||
export interface MutationState<T> {
|
||||
data: T | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/** Default options */
|
||||
export const DEFAULT_QUERY_OPTIONS: Required<QueryOptions> = {
|
||||
enabled: true,
|
||||
refetchInterval: 0,
|
||||
refetchOnFocus: true,
|
||||
staleTime: 0,
|
||||
cacheTime: 5 * 60 * 1000, // 5 minutos
|
||||
retry: 3,
|
||||
retryDelay: 1000,
|
||||
}
|
||||
|
||||
export const DEFAULT_MUTATION_OPTIONS: Required<MutationOptions> = {
|
||||
retry: 0,
|
||||
retryDelay: 1000,
|
||||
}
|
||||
Reference in New Issue
Block a user