merge: quick/mantine-cpp-new-functions — Mantine v9, C++, OSINT refactor, nuevas funciones
This commit is contained in:
+1
-1
@@ -83,7 +83,7 @@ fn-registry/
|
||||
python/functions/ # .py + .md por funcion Python
|
||||
python/types/ # .py + .md por tipo Python
|
||||
bash/functions/ # .sh + .md por funcion Bash (core, infra, io, shell)
|
||||
frontend/ # pnpm + vite + react + tailwind + shadcn
|
||||
frontend/ # pnpm + vite + react + mantine
|
||||
frontend/functions/ # .tsx/.ts + .md (core para TS puro, ui para componentes React)
|
||||
frontend/types/ # .ts + .md por tipo
|
||||
registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones
|
||||
|
||||
+114
-197
@@ -2,6 +2,17 @@
|
||||
|
||||
Eres un arquitecto frontend experto. Esta skill se activa cuando el usuario pide crear un proyecto frontend, una app con UI, un componente nuevo, o una feature frontend. Tu trabajo es garantizar que TODO el frontend se construya usando el sistema de funciones reutilizables del registry y las mejores practicas actuales.
|
||||
|
||||
## Stack
|
||||
|
||||
- **pnpm** — gestor de paquetes
|
||||
- **React 19** — UI library
|
||||
- **Vite 8** — build tool
|
||||
- **Mantine v9** — component library + styling (props, no CSS manual)
|
||||
- **Phosphor Icons** — `@phosphor-icons/react`
|
||||
- **Recharts** — charts (via `@mantine/charts`)
|
||||
|
||||
**NO usar:** Tailwind, shadcn, CVA, clsx, cn(), lucide-react, styled-components, emotion, CSS-in-JS runtime.
|
||||
|
||||
---
|
||||
|
||||
## PASO 1: Consultar el registry (OBLIGATORIO)
|
||||
@@ -56,11 +67,12 @@ apps/{nombre}/
|
||||
package.json
|
||||
vite.config.ts
|
||||
tsconfig.json
|
||||
postcss.config.cjs
|
||||
index.html
|
||||
src/
|
||||
main.tsx # Entry point
|
||||
App.tsx # Root con ThemeProvider + Router
|
||||
app.css # Tokens CSS — NUNCA hardcodear colores
|
||||
main.tsx # Entry point con MantineProvider
|
||||
App.tsx # Root con Router
|
||||
app.css # Minimal (font-smoothing solo)
|
||||
features/ # Feature-based co-location
|
||||
{feature}/
|
||||
components/ # Componentes del feature
|
||||
@@ -87,21 +99,20 @@ apps/{nombre}/
|
||||
"preview": "vite preview --host"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"@mantine/core": "^9.0.0",
|
||||
"@mantine/hooks": "^9.0.0",
|
||||
"@mantine/notifications": "^9.0.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
@@ -109,10 +120,10 @@ apps/{nombre}/
|
||||
```
|
||||
|
||||
Agregar dependencias extras segun necesidad:
|
||||
- **Charts**: `@mantine/charts`, `recharts`
|
||||
- **Tablas**: `@tanstack/react-table`
|
||||
- **Charts**: `recharts`
|
||||
- **Iconos extra**: `@phosphor-icons/react`
|
||||
- **Forms**: `react-hook-form`, `@hookform/resolvers`, `zod`
|
||||
- **Dates**: `@mantine/dates`, `dayjs`
|
||||
- **Router**: `react-router` o `@tanstack/react-router`
|
||||
- **State**: `zustand` (client state), `@tanstack/react-query` (server state)
|
||||
- **Wails**: los hooks de Wails ya estan en `@fn_library` (useWailsQuery, useWailsMutation, useWailsStream, useWailsEvent, WailsProvider)
|
||||
@@ -122,11 +133,10 @@ Agregar dependencias extras segun necesidad:
|
||||
```ts
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
@@ -134,6 +144,9 @@ export default defineConfig({
|
||||
},
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
css: {
|
||||
postcss: resolve(__dirname, './postcss.config.cjs'),
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
rollupOptions: {
|
||||
@@ -147,108 +160,32 @@ export default defineConfig({
|
||||
})
|
||||
```
|
||||
|
||||
### postcss.config.cjs base
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### app.css base
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
}
|
||||
|
||||
/* Dark theme (default) */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--background: oklch(8% 0.015 260);
|
||||
--foreground: oklch(95% 0.01 260);
|
||||
--muted: oklch(18% 0.02 260);
|
||||
--muted-foreground: oklch(60% 0.02 260);
|
||||
--border: oklch(15% 0.01 260);
|
||||
--primary: oklch(65% 0.22 260);
|
||||
--primary-foreground: oklch(98% 0.01 260);
|
||||
--secondary: oklch(20% 0.02 260);
|
||||
--secondary-foreground: oklch(95% 0.01 260);
|
||||
--accent: oklch(18% 0.03 260);
|
||||
--accent-foreground: oklch(95% 0.01 260);
|
||||
--destructive: oklch(55% 0.22 25);
|
||||
--destructive-foreground: oklch(98% 0.01 260);
|
||||
--card: oklch(11% 0.015 260);
|
||||
--card-foreground: oklch(95% 0.01 260);
|
||||
--popover: oklch(12% 0.015 260);
|
||||
--popover-foreground: oklch(95% 0.01 260);
|
||||
--ring: oklch(65% 0.22 260);
|
||||
--input: oklch(22% 0.02 260);
|
||||
--radius: 0.5rem;
|
||||
--chart-1: oklch(62% 0.19 260);
|
||||
--chart-2: oklch(65% 0.2 155);
|
||||
--chart-3: oklch(75% 0.18 85);
|
||||
--chart-4: oklch(60% 0.22 25);
|
||||
--chart-5: oklch(60% 0.2 300);
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
[data-theme="light"] {
|
||||
--background: oklch(99% 0.005 260);
|
||||
--foreground: oklch(15% 0.01 260);
|
||||
--muted: oklch(95% 0.01 260);
|
||||
--muted-foreground: oklch(45% 0.02 260);
|
||||
--border: oklch(90% 0.01 260);
|
||||
--primary: oklch(50% 0.22 260);
|
||||
--primary-foreground: oklch(98% 0.01 260);
|
||||
--secondary: oklch(95% 0.01 260);
|
||||
--secondary-foreground: oklch(20% 0.01 260);
|
||||
--accent: oklch(95% 0.02 260);
|
||||
--accent-foreground: oklch(20% 0.01 260);
|
||||
--destructive: oklch(55% 0.22 25);
|
||||
--destructive-foreground: oklch(98% 0.01 260);
|
||||
--card: oklch(100% 0 0);
|
||||
--card-foreground: oklch(15% 0.01 260);
|
||||
--popover: oklch(100% 0 0);
|
||||
--popover-foreground: oklch(15% 0.01 260);
|
||||
--ring: oklch(50% 0.22 260);
|
||||
--input: oklch(90% 0.01 260);
|
||||
--radius: 0.5rem;
|
||||
--chart-1: oklch(55% 0.22 260);
|
||||
--chart-2: oklch(55% 0.2 155);
|
||||
--chart-3: oklch(65% 0.18 85);
|
||||
--chart-4: oklch(55% 0.22 25);
|
||||
--chart-5: oklch(55% 0.2 300);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
/* Minimal — Mantine handles all theming via MantineProvider */
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -259,18 +196,33 @@ export default defineConfig({
|
||||
}
|
||||
```
|
||||
|
||||
### App.tsx base
|
||||
### main.tsx base
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider } from '@fn_library'
|
||||
import '@mantine/core/styles.css'
|
||||
import '@mantine/notifications/styles.css'
|
||||
import './app.css'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark">
|
||||
{/* Router y contenido aqui */}
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { MantineProvider, createTheme } from '@mantine/core'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
import App from './App'
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: 'blue',
|
||||
defaultRadius: 'md',
|
||||
// Customize colors, fonts, etc. here
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||
<Notifications />
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
### Despues del scaffold
|
||||
@@ -287,17 +239,16 @@ Para componentes nuevos que van al registry en `frontend/functions/`.
|
||||
|
||||
### Reglas de implementacion
|
||||
|
||||
1. **Headless first**: usar `@base-ui/react` como primitivo si el componente es interactivo (dialog, select, tooltip, etc.)
|
||||
2. **CVA para variantes**: SIEMPRE usar `class-variance-authority` para definir variantes
|
||||
3. **cn() para clases**: SIEMPRE usar `cn()` de `frontend/functions/core/cn.ts` para componer classNames
|
||||
4. **CSS variables**: NUNCA hex/rgb/oklch inline en el componente — solo clases Tailwind que mapean a CSS variables (`bg-primary`, `text-muted-foreground`, `border-border`)
|
||||
5. **Props tipadas**: usar `React.ComponentPropsWithoutRef<"element">` para HTML props spreading
|
||||
1. **Mantine first**: wrappear componentes de Mantine. Solo crear desde cero si Mantine no tiene equivalente.
|
||||
2. **Styling via props**: usar props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, `gap`, etc.) y el style system. NUNCA clases CSS manuales ni Tailwind.
|
||||
3. **CSS variables de Mantine**: si necesitas styles inline, usar `var(--mantine-color-*)`, `var(--mantine-spacing-*)`, etc.
|
||||
4. **Iconos**: usar `@phosphor-icons/react`, no lucide-react ni @tabler/icons-react.
|
||||
5. **Props tipadas**: usar `React.ComponentPropsWithoutRef<"element">` para HTML props spreading.
|
||||
6. **Accesibilidad**:
|
||||
- Elementos semanticos: `<button>` para acciones, `<a>` para navegacion, `<dialog>` para modales
|
||||
- Elementos semanticos: `<button>` para acciones, `<a>` para navegacion
|
||||
- NUNCA `<div onClick>` para elementos interactivos
|
||||
- `aria-label` o `aria-labelledby` en todo componente interactivo
|
||||
- `aria-label` en botones de solo icono
|
||||
- `aria-invalid` + `aria-describedby` en inputs con error
|
||||
- `role="status"` para loading states
|
||||
- Focus management en modales/popovers
|
||||
7. **Discriminated unions** cuando las props cambian segun variante:
|
||||
|
||||
@@ -311,54 +262,19 @@ type Props = { size?: 'sm' | 'md' | 'lg'; children: React.ReactNode } & (
|
||||
### Patron de archivo .tsx
|
||||
|
||||
```tsx
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '../core/cn'
|
||||
import { Select, type SelectProps } from '@mantine/core'
|
||||
|
||||
const componentVariants = cva(
|
||||
'base-classes-here', // clases base
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'classes...',
|
||||
secondary: 'classes...',
|
||||
},
|
||||
size: {
|
||||
sm: 'classes...',
|
||||
md: 'classes...',
|
||||
lg: 'classes...',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface ComponentProps
|
||||
extends React.ComponentPropsWithoutRef<'div'>,
|
||||
VariantProps<typeof componentVariants> {
|
||||
// props adicionales con JSDoc
|
||||
/** Descripcion de la prop */
|
||||
// Re-export con defaults o logica adicional si necesario
|
||||
interface MySelectProps extends Omit<SelectProps, 'xxx'> {
|
||||
customProp?: string
|
||||
}
|
||||
|
||||
const Component = React.forwardRef<HTMLDivElement, ComponentProps>(
|
||||
({ className, variant, size, customProp, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(componentVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Component.displayName = 'Component'
|
||||
function MySelect({ customProp, ...props }: MySelectProps) {
|
||||
return <Select {...props} />
|
||||
}
|
||||
|
||||
export { Component, componentVariants }
|
||||
export type { ComponentProps }
|
||||
export { MySelect }
|
||||
export type { MySelectProps }
|
||||
```
|
||||
|
||||
### Patron de archivo .md
|
||||
@@ -376,12 +292,12 @@ purity: impure
|
||||
signature: "ComponentName(props: ComponentProps): JSX.Element"
|
||||
description: "Descripcion concisa de que hace el componente"
|
||||
tags: [component, ui, ...]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react", "class-variance-authority"]
|
||||
imports: ["@mantine/core"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
@@ -391,14 +307,10 @@ props:
|
||||
type: "'default' | 'secondary'"
|
||||
required: false
|
||||
description: "Estilo visual"
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default, secondary]
|
||||
variant: [default]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
@@ -493,7 +405,7 @@ function useFeatureData() {
|
||||
|
||||
```tsx
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Skeleton } from '@fn_library'
|
||||
import { Skeleton } from '@mantine/core'
|
||||
|
||||
const FeaturePage = lazy(() => import('./features/feature/components/FeaturePage'))
|
||||
|
||||
@@ -501,7 +413,7 @@ function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/feature" element={
|
||||
<Suspense fallback={<Skeleton className="h-screen w-full" />}>
|
||||
<Suspense fallback={<Skeleton height="100vh" />}>
|
||||
<FeaturePage />
|
||||
</Suspense>
|
||||
} />
|
||||
@@ -517,14 +429,19 @@ function AppRoutes() {
|
||||
Antes de dar por terminado cualquier trabajo frontend, verificar:
|
||||
|
||||
### Colores y estilos
|
||||
- [ ] CERO colores hardcodeados (no hex, no rgb, no oklch inline en componentes)
|
||||
- [ ] Solo clases Tailwind mapeadas a CSS variables: `bg-primary`, `text-foreground`, `border-border`, etc.
|
||||
- [ ] `cn()` usado para merge de clases en todo componente
|
||||
- [ ] CVA usado para variantes (no condicionales manuales con ternarios)
|
||||
- [ ] CERO colores hardcodeados en componentes (no hex, no rgb inline)
|
||||
- [ ] Styling via props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, `gap`, etc.)
|
||||
- [ ] Si se necesitan styles inline, usar CSS variables de Mantine (`var(--mantine-color-*)`)
|
||||
- [ ] NO clases CSS manuales, NO Tailwind, NO cn(), NO CVA
|
||||
|
||||
### Componentes del registry
|
||||
- [ ] Verificado que no se esta recreando algo que ya existe en `@fn_library` (`frontend/functions/ui/`)
|
||||
- [ ] Componentes de `@fn_library` usados donde aplica: Alert, Badge, Button, Card, Dialog, Input, Label, Select, SimpleSelect, Skeleton, Sparkline, Tabs, Tooltip, FormField, PageHeader, ProgressBar, KPICard, ThemeProvider, DashboardLayout, DataTable, charts (AreaChart, BarChart, LineChart, PieChart, ChartContainer), hooks Wails (useWailsQuery, useWailsMutation, useWailsStream, useWailsEvent)
|
||||
- [ ] Componentes de `@fn_library` usados donde aplica: Card, Select, SimpleSelect, KPICard, Sparkline, DashboardLayout, DataTable, charts, hooks Wails
|
||||
- [ ] Componentes de Mantine usados directamente donde `@fn_library` no tiene wrapper: Button, TextInput, Table, Alert, Badge, Skeleton, Tabs, Tooltip, Group, Stack, Grid, Box, Paper, AppShell, Container
|
||||
|
||||
### Iconos
|
||||
- [ ] Usando `@phosphor-icons/react` para iconos
|
||||
- [ ] NO lucide-react, NO @tabler/icons-react
|
||||
|
||||
### TypeScript
|
||||
- [ ] Props interfaces con `React.ComponentPropsWithoutRef` para HTML spreading
|
||||
@@ -533,7 +450,7 @@ Antes de dar por terminado cualquier trabajo frontend, verificar:
|
||||
- [ ] No `any` — usar `unknown` + type guards si es necesario
|
||||
|
||||
### Accesibilidad
|
||||
- [ ] Elementos semanticos (button, a, dialog — no div onClick)
|
||||
- [ ] Elementos semanticos (button, a — no div onClick)
|
||||
- [ ] `aria-label` en botones de solo icono
|
||||
- [ ] `aria-invalid` + `aria-describedby` en inputs con validacion
|
||||
- [ ] Focus trap en modales y popovers
|
||||
@@ -555,15 +472,15 @@ Antes de dar por terminado cualquier trabajo frontend, verificar:
|
||||
|
||||
## ANTI-PATRONES (nunca hacer)
|
||||
|
||||
1. **`<div onClick={...}>`** → usar `<button>` o Base-UI primitivo
|
||||
2. **`style={{ color: '#3b82f6' }}`** → usar `className="text-primary"`
|
||||
3. **`import Button from './MyButton'`** cuando existe en la lib → usar `import { Button } from '@fn_library'`
|
||||
1. **`<div onClick={...}>`** → usar `<button>` o componente Mantine
|
||||
2. **`style={{ color: '#3b82f6' }}`** → usar prop `c="blue"` o `var(--mantine-color-blue-6)`
|
||||
3. **`import Button from './MyButton'`** cuando existe en Mantine → usar `import { Button } from '@mantine/core'`
|
||||
4. **Estado global para todo** → segmentar: server state (React Query), client state (Zustand), form state (React Hook Form), URL state (search params)
|
||||
5. **`index.ts` en la raiz de `src/`** que re-exporta todo → mata tree-shaking
|
||||
6. **`// @ts-ignore`** → arreglar el tipo
|
||||
7. **CSS-in-JS runtime** (styled-components, emotion) → usar Tailwind
|
||||
8. **Instalar shadcn/ui como dependencia** → los componentes ya estan en el registry, usar `@fn_library`
|
||||
9. **Crear utilidades que ya existen**: `cn()`, `getSeriesColor()`, `ChartContainer`, `ThemeProvider` ya estan en `@fn_library`
|
||||
10. **Colores de chart hardcodeados** → usar `--chart-1` a `--chart-5` o `getSeriesColor()`
|
||||
7. **CSS-in-JS runtime** (styled-components, emotion) → usar props de Mantine
|
||||
8. **Tailwind, CVA, cn(), clsx** → usar props de Mantine y su style system
|
||||
9. **Crear utilidades que ya existen**: `getSeriesColor()`, `ChartContainer`, `DashboardLayout`, `DataTable` ya estan en `@fn_library`
|
||||
10. **Colores de chart hardcodeados** → usar `@mantine/charts` color system o `getSeriesColor()`
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
En todos los frontends se usan los componentes de `@fn_library` (alias a `frontend/functions/ui/`) antes que elementos HTML nativos o librerias externas.
|
||||
|
||||
En todos los frontends se usa el sistema de temas basado en CSS variables (`--background`, `--foreground`, `--input`, `--border`, `--popover`, etc.) definidas en `app.css`. Los componentes deben leer estas variables para adaptarse al tema activo. Nunca hardcodear colores.
|
||||
El sistema de UI es Mantine v9. Todos los componentes de @fn_library wrappean componentes de Mantine.
|
||||
|
||||
**Theming:** Cada app define su tema con `createTheme()` de `@mantine/core` y lo pasa a `MantineProvider` (o `FnMantineProvider` de @fn_library). No se usan CSS variables custom — Mantine genera las suyas automaticamente (`--mantine-color-*`).
|
||||
|
||||
**Styling:** No se usa Tailwind, CVA, cn(), ni clases CSS manuales. Los componentes se estilizan con props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, etc.) y el style system de Mantine.
|
||||
|
||||
**Iconos:** Se usa `@tabler/icons-react` (el set nativo de Mantine), no lucide-react.
|
||||
|
||||
**Layout:** Se usan los componentes de layout de Mantine: `Group`, `Stack`, `Grid`, `Flex`, `SimpleGrid`, `AppShell`, `Container`, `Box`, `Paper`.
|
||||
|
||||
@@ -43,6 +43,9 @@ analysis/*/
|
||||
# Sources — repos externos clonados (solo se versiona el manifest)
|
||||
sources/*/
|
||||
|
||||
# C++ build artifacts
|
||||
cpp/build/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
[submodule "cpp/vendor/imgui"]
|
||||
path = cpp/vendor/imgui
|
||||
url = https://github.com/ocornut/imgui.git
|
||||
branch = docking
|
||||
[submodule "cpp/vendor/implot"]
|
||||
path = cpp/vendor/implot
|
||||
url = https://github.com/epezent/implot.git
|
||||
[submodule "cpp/vendor/tracy"]
|
||||
path = cpp/vendor/tracy
|
||||
url = https://github.com/wolfpld/tracy.git
|
||||
[submodule "/home/lucas/fn_registry/cpp/vendor/glfw"]
|
||||
path = /home/lucas/fn_registry/cpp/vendor/glfw
|
||||
url = https://github.com/glfw/glfw.git
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: build_cpp_linux
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "build_cpp_linux(target?: string) -> void"
|
||||
description: "Compila las funciones y apps C++ del registry para Linux nativo usando cmake"
|
||||
tags: [cpp, build, cmake, linux, imgui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/build_cpp_linux.sh"
|
||||
params:
|
||||
- name: target
|
||||
desc: "Nombre del target cmake a compilar (opcional, sin argumento compila todo)"
|
||||
output: "Compila los binarios en cpp/build/linux/"
|
||||
---
|
||||
|
||||
# build_cpp_linux
|
||||
|
||||
Configura y compila el proyecto C++ (ImGui/ImPlot) para Linux nativo.
|
||||
|
||||
Usa cmake con compilacion paralela (`-j$(nproc)`). Si no se ha configurado antes, ejecuta `cmake -B` automaticamente.
|
||||
|
||||
```bash
|
||||
fn run build_cpp_linux # Compilar todo
|
||||
fn run build_cpp_linux chart_demo # Compilar solo chart_demo
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}"
|
||||
CPP_ROOT="$REGISTRY_ROOT/cpp"
|
||||
BUILD_DIR="$CPP_ROOT/build/linux"
|
||||
TARGET="${1:-}"
|
||||
|
||||
# Configure if needed
|
||||
if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then
|
||||
echo "[build_cpp_linux] Configuring cmake..."
|
||||
cmake -B "$BUILD_DIR" -S "$CPP_ROOT"
|
||||
fi
|
||||
|
||||
# Build
|
||||
if [ -n "$TARGET" ]; then
|
||||
echo "[build_cpp_linux] Building target: $TARGET"
|
||||
cmake --build "$BUILD_DIR" --target "$TARGET" -- -j"$(nproc)"
|
||||
else
|
||||
echo "[build_cpp_linux] Building all targets..."
|
||||
cmake --build "$BUILD_DIR" -- -j"$(nproc)"
|
||||
fi
|
||||
|
||||
echo "[build_cpp_linux] Done. Binaries in $BUILD_DIR"
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: build_cpp_windows
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "build_cpp_windows(target?: string) -> void"
|
||||
description: "Cross-compila las funciones y apps C++ del registry para Windows usando mingw-w64"
|
||||
tags: [cpp, build, cmake, windows, cross-compile, mingw, imgui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/build_cpp_windows.sh"
|
||||
params:
|
||||
- name: target
|
||||
desc: "Nombre del target cmake a compilar (opcional, sin argumento compila todo)"
|
||||
output: "Produce binarios .exe de Windows en cpp/build/windows/"
|
||||
---
|
||||
|
||||
# build_cpp_windows
|
||||
|
||||
Cross-compila el proyecto C++ para Windows desde Linux usando el toolchain mingw-w64.
|
||||
|
||||
Los .exe resultantes incluyen runtime linkado estaticamente (self-contained).
|
||||
|
||||
```bash
|
||||
fn run build_cpp_windows # Compilar todo
|
||||
fn run build_cpp_windows chart_demo # Compilar solo chart_demo
|
||||
```
|
||||
|
||||
Requiere `mingw-w64`: `sudo apt install mingw-w64`
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}"
|
||||
CPP_ROOT="$REGISTRY_ROOT/cpp"
|
||||
BUILD_DIR="$CPP_ROOT/build/windows"
|
||||
TOOLCHAIN="$CPP_ROOT/toolchains/mingw-w64.cmake"
|
||||
TARGET="${1:-}"
|
||||
|
||||
# Check mingw is available
|
||||
if ! command -v x86_64-w64-mingw32-g++ &>/dev/null; then
|
||||
echo "[build_cpp_windows] Error: mingw-w64 not found. Install with: sudo apt install mingw-w64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configure if needed
|
||||
if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then
|
||||
echo "[build_cpp_windows] Configuring cmake with mingw-w64 toolchain..."
|
||||
cmake -B "$BUILD_DIR" -S "$CPP_ROOT" -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN"
|
||||
fi
|
||||
|
||||
# Build
|
||||
if [ -n "$TARGET" ]; then
|
||||
echo "[build_cpp_windows] Cross-compiling target: $TARGET"
|
||||
cmake --build "$BUILD_DIR" --target "$TARGET" -- -j"$(nproc)"
|
||||
else
|
||||
echo "[build_cpp_windows] Cross-compiling all targets..."
|
||||
cmake --build "$BUILD_DIR" -- -j"$(nproc)"
|
||||
fi
|
||||
|
||||
echo "[build_cpp_windows] Done. Windows binaries in $BUILD_DIR"
|
||||
if [ -n "$TARGET" ]; then
|
||||
file "$BUILD_DIR"/**/"$TARGET".exe 2>/dev/null || file "$BUILD_DIR/$TARGET".exe 2>/dev/null || true
|
||||
fi
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: frontend_doctor
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "frontend_doctor(project_dir: string) -> diagnostics_stdout"
|
||||
description: "Diagnostica la salud de un proyecto frontend Mantine. Verifica Node, React, Mantine, PostCSS, TypeScript, vite.config y detecta residuos de shadcn/@base-ui. Imprime tabla de checks con exit code 0/1."
|
||||
tags: [frontend, mantine, doctor, diagnostics, health, validation]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: project_dir
|
||||
desc: "directorio del proyecto frontend con package.json"
|
||||
output: "tabla de checks con ✓/✗ por cada validación y resumen final"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/frontend_doctor.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Diagnosticar un proyecto
|
||||
bash frontend_doctor.sh ./apps/rapid_dashboards/frontend
|
||||
|
||||
# Output:
|
||||
# === Frontend Doctor: ./apps/rapid_dashboards/frontend ===
|
||||
#
|
||||
# ✓ Node >= 18 22.12.0
|
||||
# ✓ Package manager detected pnpm
|
||||
# ✓ node_modules present
|
||||
# ✓ @mantine/core 7.17.0
|
||||
# ✓ @mantine/hooks
|
||||
# ✓ @mantine/charts
|
||||
# ✓ React >= 18 19.2.4
|
||||
# ✓ postcss.config present
|
||||
# ✓ TypeScript >= 5 6.0.2
|
||||
# ✓ vite.config present
|
||||
# ✓ No shadcn residual
|
||||
# ✓ No @base-ui residual
|
||||
#
|
||||
# Resultado: todo OK
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Checks informativos, no modifica nada. Util para validar que un proyecto esta correctamente configurado despues de instalar Mantine o migrar desde shadcn. Exit code 0 si todo OK, 1 si hay problemas.
|
||||
@@ -0,0 +1,150 @@
|
||||
# frontend_doctor
|
||||
# ----------------
|
||||
# Diagnostica la salud de un proyecto frontend Mantine.
|
||||
# Verifica dependencias, configuracion y versiones.
|
||||
# Imprime tabla de checks y retorna exit code 0 (ok) o 1 (fallos).
|
||||
#
|
||||
# USO (sourced):
|
||||
# source frontend_doctor.sh
|
||||
# frontend_doctor /path/to/frontend
|
||||
#
|
||||
# USO (directo):
|
||||
# bash frontend_doctor.sh /path/to/frontend
|
||||
|
||||
frontend_doctor() {
|
||||
local project_dir="$1"
|
||||
local failures=0
|
||||
|
||||
if [ -z "$project_dir" ]; then
|
||||
echo "frontend_doctor: se requiere project_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$project_dir/package.json" ]; then
|
||||
echo "frontend_doctor: no existe package.json en $project_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "=== Frontend Doctor: $project_dir ==="
|
||||
echo ""
|
||||
|
||||
# Helper: check y reportar
|
||||
_check() {
|
||||
local label="$1"
|
||||
local ok="$2"
|
||||
local detail="$3"
|
||||
if [ "$ok" = "1" ]; then
|
||||
printf " ✓ %-35s %s\n" "$label" "$detail"
|
||||
else
|
||||
printf " ✗ %-35s %s\n" "$label" "$detail"
|
||||
((failures++))
|
||||
fi
|
||||
}
|
||||
|
||||
# 1. Node >= 18
|
||||
local node_ver=""
|
||||
local node_ok=0
|
||||
if command -v node &>/dev/null; then
|
||||
node_ver=$(node -v 2>/dev/null | sed 's/v//')
|
||||
local node_major=$(echo "$node_ver" | cut -d. -f1)
|
||||
[ "$node_major" -ge 18 ] 2>/dev/null && node_ok=1
|
||||
fi
|
||||
_check "Node >= 18" "$node_ok" "${node_ver:-not found}"
|
||||
|
||||
# 2. Package manager
|
||||
local pm_ok=0
|
||||
local pm_name="none"
|
||||
if [ -f "$project_dir/pnpm-lock.yaml" ]; then
|
||||
pm_name="pnpm"; pm_ok=1
|
||||
elif [ -f "$project_dir/yarn.lock" ]; then
|
||||
pm_name="yarn"; pm_ok=1
|
||||
elif [ -f "$project_dir/package-lock.json" ]; then
|
||||
pm_name="npm"; pm_ok=1
|
||||
fi
|
||||
_check "Package manager detected" "$pm_ok" "$pm_name"
|
||||
|
||||
# 3. node_modules existe
|
||||
local nm_ok=0
|
||||
[ -d "$project_dir/node_modules" ] && nm_ok=1
|
||||
_check "node_modules present" "$nm_ok" ""
|
||||
|
||||
# 4. @mantine/core instalado
|
||||
local mantine_ok=0
|
||||
local mantine_ver=""
|
||||
if [ -f "$project_dir/node_modules/@mantine/core/package.json" ]; then
|
||||
mantine_ver=$(node -e "console.log(require('$project_dir/node_modules/@mantine/core/package.json').version)" 2>/dev/null)
|
||||
mantine_ok=1
|
||||
fi
|
||||
_check "@mantine/core" "$mantine_ok" "${mantine_ver:-not installed}"
|
||||
|
||||
# 5. @mantine/hooks
|
||||
local hooks_ok=0
|
||||
[ -d "$project_dir/node_modules/@mantine/hooks" ] && hooks_ok=1
|
||||
_check "@mantine/hooks" "$hooks_ok" ""
|
||||
|
||||
# 6. @mantine/charts
|
||||
local charts_ok=0
|
||||
[ -d "$project_dir/node_modules/@mantine/charts" ] && charts_ok=1
|
||||
_check "@mantine/charts" "$charts_ok" ""
|
||||
|
||||
# 7. React >= 18
|
||||
local react_ok=0
|
||||
local react_ver=""
|
||||
if [ -f "$project_dir/node_modules/react/package.json" ]; then
|
||||
react_ver=$(node -e "console.log(require('$project_dir/node_modules/react/package.json').version)" 2>/dev/null)
|
||||
local react_major=$(echo "$react_ver" | cut -d. -f1)
|
||||
[ "$react_major" -ge 18 ] 2>/dev/null && react_ok=1
|
||||
fi
|
||||
_check "React >= 18" "$react_ok" "${react_ver:-not found}"
|
||||
|
||||
# 8. postcss.config presente
|
||||
local postcss_ok=0
|
||||
if [ -f "$project_dir/postcss.config.cjs" ] || [ -f "$project_dir/postcss.config.js" ] || [ -f "$project_dir/postcss.config.mjs" ]; then
|
||||
postcss_ok=1
|
||||
fi
|
||||
_check "postcss.config present" "$postcss_ok" ""
|
||||
|
||||
# 9. TypeScript >= 5
|
||||
local ts_ok=0
|
||||
local ts_ver=""
|
||||
if [ -f "$project_dir/node_modules/typescript/package.json" ]; then
|
||||
ts_ver=$(node -e "console.log(require('$project_dir/node_modules/typescript/package.json').version)" 2>/dev/null)
|
||||
local ts_major=$(echo "$ts_ver" | cut -d. -f1)
|
||||
[ "$ts_major" -ge 5 ] 2>/dev/null && ts_ok=1
|
||||
fi
|
||||
_check "TypeScript >= 5" "$ts_ok" "${ts_ver:-not found}"
|
||||
|
||||
# 10. vite.config presente
|
||||
local vite_ok=0
|
||||
if [ -f "$project_dir/vite.config.ts" ] || [ -f "$project_dir/vite.config.js" ]; then
|
||||
vite_ok=1
|
||||
fi
|
||||
_check "vite.config present" "$vite_ok" ""
|
||||
|
||||
# 11. Shadcn residual (warning)
|
||||
local shadcn_clean=1
|
||||
if [ -f "$project_dir/components.json" ]; then
|
||||
shadcn_clean=0
|
||||
fi
|
||||
_check "No shadcn residual" "$shadcn_clean" "$([ "$shadcn_clean" = "0" ] && echo 'components.json found')"
|
||||
|
||||
# 12. @base-ui residual (warning)
|
||||
local baseui_clean=1
|
||||
if [ -d "$project_dir/node_modules/@base-ui" ]; then
|
||||
baseui_clean=0
|
||||
fi
|
||||
_check "No @base-ui residual" "$baseui_clean" "$([ "$baseui_clean" = "0" ] && echo '@base-ui still installed')"
|
||||
|
||||
echo ""
|
||||
if [ "$failures" -eq 0 ]; then
|
||||
echo " Resultado: todo OK"
|
||||
return 0
|
||||
else
|
||||
echo " Resultado: $failures problema(s) encontrado(s)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
frontend_doctor "$@"
|
||||
fi
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: gitea_add_collaborator
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "gitea_add_collaborator(owner: string, repo: string, username: string, permission: string) -> void"
|
||||
description: "Añade un colaborador a un repositorio Gitea con el nivel de permisos indicado. Silencioso si el colaborador ya existe (422)."
|
||||
tags: [gitea, git, collaborator, permission, repo, api, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: owner
|
||||
desc: "usuario u organización propietaria del repositorio"
|
||||
- name: repo
|
||||
desc: "nombre del repositorio"
|
||||
- name: username
|
||||
desc: "nombre de usuario del colaborador a añadir"
|
||||
- name: permission
|
||||
desc: "nivel de permisos: 'read', 'write' o 'admin' (default: admin)"
|
||||
output: "vacío — efectos observables a través de la API de Gitea"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/gitea_add_collaborator.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/gitea_add_collaborator.sh
|
||||
|
||||
export GITEA_URL="https://git.example.com"
|
||||
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||
|
||||
# Añadir colaborador con permiso admin (default)
|
||||
gitea_add_collaborator "myorg" "my-app" "egutierrez"
|
||||
|
||||
# Añadir colaborador con permiso de solo lectura
|
||||
gitea_add_collaborator "myorg" "my-app" "reviewer" "read"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
|
||||
- Un 422 de la API indica que el usuario ya es colaborador — se trata como éxito silencioso.
|
||||
- La función no produce salida a stdout; los mensajes informativos van a stderr.
|
||||
- Nivel `admin` da acceso completo al repo incluyendo settings.
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea_add_collaborator — Añade un colaborador a un repositorio Gitea
|
||||
|
||||
gitea_add_collaborator() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local username="$3"
|
||||
local permission="${4:-admin}"
|
||||
|
||||
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||
echo "gitea_add_collaborator: GITEA_URL no está seteada" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "gitea_add_collaborator: GITEA_TOKEN no está seteado" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$owner" || -z "$repo" || -z "$username" ]]; then
|
||||
echo "gitea_add_collaborator: se requieren owner, repo y username" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local payload
|
||||
payload=$(printf '{"permission":"%s"}' "$permission")
|
||||
|
||||
echo "gitea_add_collaborator: añadiendo '$username' a '$owner/$repo' con permiso '$permission'..." >&2
|
||||
|
||||
local response http_code
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-d "$payload" \
|
||||
"${GITEA_URL}/api/v1/repos/${owner}/${repo}/collaborators/${username}")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
local body
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [[ "$http_code" == "204" || "$http_code" == "200" ]]; then
|
||||
echo "gitea_add_collaborator: '$username' añadido a '$owner/$repo'" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$http_code" == "422" ]]; then
|
||||
echo "gitea_add_collaborator: '$username' ya es colaborador de '$owner/$repo' (silencioso)" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "gitea_add_collaborator: error (HTTP ${http_code}): ${body}" >&2
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: gitea_create_repo
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "gitea_create_repo(owner: string, name: string, private: string, description: string) -> string"
|
||||
description: "Crea un repositorio en Gitea para un owner. Intenta crearlo en org primero; si el owner no es una org (404/422), lo crea en el usuario autenticado. No falla fatalmente si el repo ya existe (409)."
|
||||
tags: [gitea, git, repo, create, infra, api]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: owner
|
||||
desc: "usuario u organización propietaria del repo"
|
||||
- name: name
|
||||
desc: "nombre del repositorio a crear"
|
||||
- name: private
|
||||
desc: "si el repo es privado, 'true' o 'false' (default: false)"
|
||||
- name: description
|
||||
desc: "descripción del repositorio (opcional)"
|
||||
output: "JSON del repositorio creado según la API de Gitea"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/gitea_create_repo.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/gitea_create_repo.sh
|
||||
|
||||
export GITEA_URL="https://git.example.com"
|
||||
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||
|
||||
# Crear repo público en org o usuario
|
||||
repo_json=$(gitea_create_repo "myorg" "my-app")
|
||||
|
||||
# Crear repo privado con descripción
|
||||
repo_json=$(gitea_create_repo "myorg" "my-app" "true" "Mi aplicación principal")
|
||||
|
||||
# Extraer la URL del clon
|
||||
clone_url=$(echo "$repo_json" | jq -r '.clone_url')
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere variables de entorno `GITEA_URL` y `GITEA_TOKEN` seteadas antes de invocar.
|
||||
- El fallback org → usuario ocurre con HTTP 404 o 422 en el endpoint de orgs.
|
||||
- Un 409 se reporta a stderr pero la función retorna 0 — el repo ya existe es una condición aceptable para idempotencia.
|
||||
- Los mensajes informativos van a stderr; el JSON de respuesta va a stdout.
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea_create_repo — Crea un repositorio en Gitea para un owner (org o usuario)
|
||||
|
||||
gitea_create_repo() {
|
||||
local owner="$1"
|
||||
local name="$2"
|
||||
local private="${3:-false}"
|
||||
local description="${4:-}"
|
||||
|
||||
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||
echo "gitea_create_repo: GITEA_URL no está seteada" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "gitea_create_repo: GITEA_TOKEN no está seteado" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$owner" || -z "$name" ]]; then
|
||||
echo "gitea_create_repo: se requieren owner y name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local payload
|
||||
payload=$(printf '{"name":"%s","private":%s,"description":"%s","auto_init":false}' \
|
||||
"$name" "$private" "$description")
|
||||
|
||||
echo "gitea_create_repo: intentando crear '$owner/$name' en org..." >&2
|
||||
|
||||
local response http_code
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-d "$payload" \
|
||||
"${GITEA_URL}/api/v1/orgs/${owner}/repos")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
local body
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [[ "$http_code" == "201" ]]; then
|
||||
echo "gitea_create_repo: repo '$owner/$name' creado en org" >&2
|
||||
echo "$body"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$http_code" == "409" ]]; then
|
||||
echo "gitea_create_repo: repo '$owner/$name' ya existe (409)" >&2
|
||||
echo "$body"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$http_code" == "404" || "$http_code" == "422" ]]; then
|
||||
echo "gitea_create_repo: org no encontrada (${http_code}), intentando en usuario..." >&2
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-d "$payload" \
|
||||
"${GITEA_URL}/api/v1/user/repos")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [[ "$http_code" == "201" ]]; then
|
||||
echo "gitea_create_repo: repo '$owner/$name' creado en usuario" >&2
|
||||
echo "$body"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$http_code" == "409" ]]; then
|
||||
echo "gitea_create_repo: repo '$owner/$name' ya existe (409)" >&2
|
||||
echo "$body"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "gitea_create_repo: error al crear en usuario (HTTP ${http_code}): ${body}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "gitea_create_repo: error inesperado (HTTP ${http_code}): ${body}" >&2
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: gitea_list_repos
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "gitea_list_repos(owner: string) -> string"
|
||||
description: "Lista repositorios de un owner en Gitea. Intenta listar como org primero; si falla, lista como usuario. Imprime una línea por repo en formato name<TAB>html_url<TAB>description."
|
||||
tags: [gitea, git, repo, list, org, user, api, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: owner
|
||||
desc: "nombre del usuario u organización cuyos repos se listan"
|
||||
output: "una línea por repositorio con columnas separadas por tabulador: name, html_url, description"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/gitea_list_repos.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/gitea_list_repos.sh
|
||||
|
||||
export GITEA_URL="https://git.example.com"
|
||||
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||
|
||||
# Listar todos los repos de una org
|
||||
gitea_list_repos "myorg"
|
||||
# my-app https://git.example.com/myorg/my-app Mi aplicación principal
|
||||
# infra https://git.example.com/myorg/infra
|
||||
|
||||
# Iterar sobre los repos
|
||||
while IFS=$'\t' read -r name url desc; do
|
||||
echo "Repo: $name — $url"
|
||||
done < <(gitea_list_repos "myorg")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
|
||||
- Usa `jq` si está disponible; fallback con grep/sed en caso contrario.
|
||||
- El límite es 50 repos por página. Para owners con más de 50 repos habría que implementar paginación.
|
||||
- Los mensajes informativos van a stderr; los datos tabulados van a stdout.
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea_list_repos — Lista repositorios de un owner (org o usuario) en Gitea
|
||||
|
||||
gitea_list_repos() {
|
||||
local owner="$1"
|
||||
|
||||
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||
echo "gitea_list_repos: GITEA_URL no está seteada" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "gitea_list_repos: GITEA_TOKEN no está seteado" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$owner" ]]; then
|
||||
echo "gitea_list_repos: se requiere owner" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "gitea_list_repos: listando repos de '$owner' (intentando org)..." >&2
|
||||
|
||||
local response http_code body
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/orgs/${owner}/repos?limit=50")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
echo "gitea_list_repos: org no encontrada (${http_code}), intentando usuario..." >&2
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/users/${owner}/repos?limit=50")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
echo "gitea_list_repos: error listando repos de usuario (HTTP ${http_code}): ${body}" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Formatear salida como name\thtml_url\tdescription
|
||||
if command -v jq &>/dev/null; then
|
||||
echo "$body" | jq -r '.[] | [.name, .html_url, (.description // "")] | @tsv'
|
||||
else
|
||||
# Fallback sin jq: extraer campos básicos con grep/sed
|
||||
echo "$body" | grep -o '"name":"[^"]*"\|"html_url":"[^"]*"\|"description":"[^"]*"' \
|
||||
| paste - - - | sed 's/"name":"//;s/"html_url":"//;s/"description":"//;s/"//g'
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: gitea_push_directory
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "gitea_push_directory(directory: string, owner: string, repo: string, branch: string) -> void"
|
||||
description: "Inicializa git en un directorio local y lo sube a un repositorio Gitea existente. Si el directorio ya tiene .git, actualiza el remote y pushea cambios pendientes. Protege registry.db añadiéndolo al .gitignore antes del commit."
|
||||
tags: [gitea, git, push, directory, sync, infra, repo]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: directory
|
||||
desc: "ruta absoluta o relativa al directorio local a subir"
|
||||
- name: owner
|
||||
desc: "usuario u organización propietaria del repositorio Gitea destino"
|
||||
- name: repo
|
||||
desc: "nombre del repositorio Gitea destino (debe existir previamente)"
|
||||
- name: branch
|
||||
desc: "rama en la que hacer push (default: main)"
|
||||
output: "vacío — efectos observables en el repositorio Gitea destino"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/gitea_push_directory.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/gitea_push_directory.sh
|
||||
|
||||
export GITEA_URL="https://git.example.com"
|
||||
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||
|
||||
# Subir directorio a repo existente
|
||||
gitea_push_directory "/home/lucas/myproject" "myorg" "my-app"
|
||||
|
||||
# Subir a rama específica
|
||||
gitea_push_directory "/home/lucas/myproject" "myorg" "my-app" "develop"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
|
||||
- El token se embebe en la URL del remote para autenticación (nunca se imprime a stdout/stderr, se enmascara con ***).
|
||||
- Si `registry.db` existe en el directorio, se añade automáticamente al `.gitignore` local.
|
||||
- Si el `.git` ya existe con un remote diferente, se redirige al repo indicado sin perder el historial local.
|
||||
- Usa `--force-with-lease` para el primer push y fallback a push normal (para repos vacíos recién creados).
|
||||
- El commit se firma con `agent@fn-registry` si no hay configuración git en el entorno.
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea_push_directory — Inicializa git en un directorio y lo sube a un repo Gitea existente
|
||||
|
||||
gitea_push_directory() {
|
||||
local directory="$1"
|
||||
local owner="$2"
|
||||
local repo="$3"
|
||||
local branch="${4:-main}"
|
||||
|
||||
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||
echo "gitea_push_directory: GITEA_URL no está seteada" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "gitea_push_directory: GITEA_TOKEN no está seteado" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$directory" || -z "$owner" || -z "$repo" ]]; then
|
||||
echo "gitea_push_directory: se requieren directory, owner y repo" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -d "$directory" ]]; then
|
||||
echo "gitea_push_directory: directorio '$directory' no existe" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Construir URL con credenciales embebidas para autenticación
|
||||
local gitea_host
|
||||
gitea_host=$(echo "$GITEA_URL" | sed 's|https\?://||')
|
||||
local remote_url="https://${GITEA_TOKEN}@${gitea_host}/${owner}/${repo}.git"
|
||||
local display_url="https://***@${gitea_host}/${owner}/${repo}.git"
|
||||
|
||||
echo "gitea_push_directory: procesando '$directory' → '$owner/$repo' (rama: $branch)..." >&2
|
||||
|
||||
# Añadir registry.db al .gitignore local si existe en el directorio
|
||||
if [[ -f "$directory/registry.db" ]]; then
|
||||
echo "gitea_push_directory: añadiendo registry.db al .gitignore..." >&2
|
||||
if [[ ! -f "$directory/.gitignore" ]] || ! grep -qxF "registry.db" "$directory/.gitignore"; then
|
||||
echo "registry.db" >> "$directory/.gitignore"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Gestionar estado del repositorio git
|
||||
if [[ -d "$directory/.git" ]]; then
|
||||
local existing_remote
|
||||
existing_remote=$(git -C "$directory" remote get-url origin 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$existing_remote" ]]; then
|
||||
echo "gitea_push_directory: añadiendo remote origin..." >&2
|
||||
git -C "$directory" remote add origin "$remote_url"
|
||||
else
|
||||
# Comparar remote sin token para detectar si apunta al mismo repo
|
||||
local clean_existing
|
||||
clean_existing=$(echo "$existing_remote" | sed 's|https://[^@]*@||;s|https://||')
|
||||
local clean_target="${gitea_host}/${owner}/${repo}.git"
|
||||
|
||||
if [[ "$clean_existing" != "$clean_target" ]]; then
|
||||
echo "gitea_push_directory: remote apunta a otro destino ('$clean_existing'), actualizando..." >&2
|
||||
git -C "$directory" remote set-url origin "$remote_url"
|
||||
else
|
||||
echo "gitea_push_directory: remote ya apunta al destino correcto, actualizando token..." >&2
|
||||
git -C "$directory" remote set-url origin "$remote_url"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "gitea_push_directory: inicializando nuevo repositorio git..." >&2
|
||||
git -C "$directory" init
|
||||
git -C "$directory" remote add origin "$remote_url"
|
||||
fi
|
||||
|
||||
# Configurar rama por defecto
|
||||
git -C "$directory" checkout -B "$branch" 2>/dev/null || true
|
||||
|
||||
# Añadir y commitear cambios si los hay
|
||||
git -C "$directory" add -A
|
||||
|
||||
local status
|
||||
status=$(git -C "$directory" status --porcelain)
|
||||
|
||||
if [[ -n "$status" ]]; then
|
||||
echo "gitea_push_directory: commiteando cambios..." >&2
|
||||
git -C "$directory" -c user.email="agent@fn-registry" -c user.name="fn-registry agent" \
|
||||
commit -m "chore: sync from fn-registry agent"
|
||||
else
|
||||
echo "gitea_push_directory: sin cambios pendientes, solo haciendo push..." >&2
|
||||
fi
|
||||
|
||||
echo "gitea_push_directory: haciendo push a $display_url..." >&2
|
||||
git -C "$directory" push --set-upstream origin "$branch" --force-with-lease 2>&1 \
|
||||
| sed "s|${GITEA_TOKEN}|***|g" >&2 \
|
||||
|| git -C "$directory" push --set-upstream origin "$branch" 2>&1 \
|
||||
| sed "s|${GITEA_TOKEN}|***|g" >&2
|
||||
|
||||
echo "gitea_push_directory: push completado a '$owner/$repo' rama '$branch'" >&2
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: install_android_sdk
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "install_android_sdk() -> void"
|
||||
description: "Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source."
|
||||
tags: [android, sdk, jdk, java, install, infra, mobile]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params: []
|
||||
output: "sin salida estructurada; imprime progreso y resumen final con rutas de instalacion"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/install_android_sdk.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Instalacion en directorio por defecto ($HOME/android-sdk)
|
||||
source install_android_sdk.sh
|
||||
|
||||
# Instalacion en directorio personalizado
|
||||
ANDROID_SDK_DIR=/opt/android source install_android_sdk.sh
|
||||
|
||||
# Si ya esta instalado:
|
||||
# Android SDK ya instalado en: /home/user/android-sdk
|
||||
|
||||
# Instalacion completa imprime:
|
||||
# Descargando JDK 17...
|
||||
# JDK 17 instalado: /home/user/android-sdk/jdk-17/jdk-17.0.x+y
|
||||
# Descargando Android cmdline-tools...
|
||||
# cmdline-tools instalados
|
||||
# Aceptando licencias de Android SDK...
|
||||
# Instalando platform-tools, platforms;android-34, build-tools;34.0.0...
|
||||
#
|
||||
# Android SDK instalado en: /home/user/android-sdk
|
||||
# JDK 17: /home/user/android-sdk/jdk-17/jdk-17.0.x+y
|
||||
# Para activar: source /home/user/android-sdk/env.sh
|
||||
|
||||
# Activar entorno en sesion actual
|
||||
source ~/android-sdk/env.sh
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.
|
||||
|
||||
El JDK se descarga desde Adoptium (Eclipse Temurin) via su API oficial. La URL de cmdline-tools apunta a la version 11076708 (2024). Si Google actualiza la version, cambiar la URL con el nuevo numero de build.
|
||||
|
||||
La reorganizacion del zip es necesaria porque Google distribuye cmdline-tools con estructura `cmdline-tools/bin/...` pero sdkmanager espera estar en `cmdline-tools/latest/bin/sdkmanager` para que Android Studio y otras herramientas lo detecten correctamente.
|
||||
|
||||
El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.
|
||||
|
||||
Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# install_android_sdk — Descarga e instala Android SDK command-line tools y JDK 17
|
||||
# localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk).
|
||||
set -euo pipefail
|
||||
|
||||
install_android_sdk() {
|
||||
local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
|
||||
# Limpia temporales al salir
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
# 1. Verifica si ya está instalado
|
||||
if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||
if JAVA_HOME="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1)" \
|
||||
"$sdk_dir/cmdline-tools/latest/bin/sdkmanager" --version &>/dev/null; then
|
||||
echo "Android SDK ya instalado en: $sdk_dir"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$sdk_dir"
|
||||
|
||||
# 2. Descarga JDK 17 si no existe
|
||||
local jdk_dir
|
||||
jdk_dir="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1 || true)"
|
||||
|
||||
if [[ -z "$jdk_dir" ]]; then
|
||||
echo "Descargando JDK 17..."
|
||||
local jdk_tar="$tmp_dir/jdk17.tar.gz"
|
||||
local jdk_url="https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"
|
||||
|
||||
if ! curl -fL --progress-bar -o "$jdk_tar" "$jdk_url"; then
|
||||
echo "ERROR: fallo al descargar JDK 17 desde $jdk_url" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
mkdir -p "$sdk_dir/jdk-17"
|
||||
echo "Extrayendo JDK 17..."
|
||||
if ! tar -xzf "$jdk_tar" -C "$sdk_dir/jdk-17"; then
|
||||
echo "ERROR: fallo al extraer JDK 17" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
jdk_dir="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1 || true)"
|
||||
if [[ -z "$jdk_dir" ]]; then
|
||||
echo "ERROR: no se encontro directorio jdk-17* tras la extraccion" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! JAVA_HOME="$jdk_dir" "$jdk_dir/bin/java" -version &>/dev/null; then
|
||||
echo "ERROR: java -version fallo tras instalar JDK" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "JDK 17 instalado: $jdk_dir"
|
||||
else
|
||||
echo "JDK 17 ya presente: $jdk_dir"
|
||||
fi
|
||||
|
||||
export JAVA_HOME="$jdk_dir"
|
||||
|
||||
# 3. Descarga Android cmdline-tools si no existen
|
||||
if [[ ! -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||
echo "Descargando Android cmdline-tools..."
|
||||
local tools_zip="$tmp_dir/cmdline-tools.zip"
|
||||
local tools_url="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
|
||||
|
||||
if ! curl -fL --progress-bar -o "$tools_zip" "$tools_url"; then
|
||||
echo "ERROR: fallo al descargar Android cmdline-tools desde $tools_url" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tools_tmp="$tmp_dir/cmdline-tools-extracted"
|
||||
mkdir -p "$tools_tmp"
|
||||
echo "Extrayendo cmdline-tools..."
|
||||
if ! unzip -q "$tools_zip" -d "$tools_tmp"; then
|
||||
echo "ERROR: fallo al extraer cmdline-tools" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# La estructura del zip es cmdline-tools/bin/..., reorganizar a cmdline-tools/latest/
|
||||
mkdir -p "$sdk_dir/cmdline-tools"
|
||||
if [[ -d "$tools_tmp/cmdline-tools" ]]; then
|
||||
mv "$tools_tmp/cmdline-tools" "$sdk_dir/cmdline-tools/latest"
|
||||
else
|
||||
echo "ERROR: estructura inesperada en el zip de cmdline-tools" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||
echo "ERROR: sdkmanager no encontrado tras extraer cmdline-tools" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "cmdline-tools instalados"
|
||||
else
|
||||
echo "cmdline-tools ya presentes"
|
||||
fi
|
||||
|
||||
local sdkmanager="$sdk_dir/cmdline-tools/latest/bin/sdkmanager"
|
||||
export ANDROID_HOME="$sdk_dir"
|
||||
export ANDROID_SDK_ROOT="$sdk_dir"
|
||||
export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"
|
||||
|
||||
# 4. Acepta licencias e instala paquetes necesarios
|
||||
echo "Aceptando licencias de Android SDK..."
|
||||
if ! yes | "$sdkmanager" --licenses; then
|
||||
echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
|
||||
if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
|
||||
echo "ERROR: fallo al instalar paquetes de Android SDK" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 5. Genera archivo de entorno
|
||||
local env_file="$sdk_dir/env.sh"
|
||||
cat > "$env_file" <<EOF
|
||||
export JAVA_HOME="$JAVA_HOME"
|
||||
export ANDROID_HOME="$sdk_dir"
|
||||
export ANDROID_SDK_ROOT="$sdk_dir"
|
||||
export PATH="\$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:\$PATH"
|
||||
EOF
|
||||
|
||||
# 6. Resumen final
|
||||
echo ""
|
||||
echo "Android SDK instalado en: $sdk_dir"
|
||||
echo "JDK 17: $JAVA_HOME"
|
||||
echo "Para activar: source $sdk_dir/env.sh"
|
||||
}
|
||||
|
||||
install_android_sdk
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: install_cpp_deps
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "install_cpp_deps() -> void"
|
||||
description: "Verifica e instala las dependencias de sistema necesarias para compilar C++ con ImGui (cmake, g++, glfw, mesa)"
|
||||
tags: [cpp, dependencies, setup, cmake, imgui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/install_cpp_deps.sh"
|
||||
params: []
|
||||
output: "Instala paquetes faltantes via apt o confirma que todo esta instalado"
|
||||
---
|
||||
|
||||
# install_cpp_deps
|
||||
|
||||
Verifica las dependencias necesarias para el build C++:
|
||||
- `cmake` — sistema de build
|
||||
- `g++` / `build-essential` — compilador
|
||||
- `libglfw3-dev` — windowing (GLFW)
|
||||
- `libgl1-mesa-dev` — OpenGL headers
|
||||
|
||||
Tambien reporta si `mingw-w64` esta disponible para cross-compile a Windows.
|
||||
|
||||
```bash
|
||||
fn run install_cpp_deps
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "[install_cpp_deps] Checking C++ build dependencies..."
|
||||
|
||||
MISSING=()
|
||||
|
||||
if ! command -v cmake &>/dev/null; then
|
||||
MISSING+=(cmake)
|
||||
else
|
||||
echo " cmake: $(cmake --version | head -1)"
|
||||
fi
|
||||
|
||||
if ! command -v g++ &>/dev/null; then
|
||||
MISSING+=(g++ build-essential)
|
||||
else
|
||||
echo " g++: $(g++ --version | head -1)"
|
||||
fi
|
||||
|
||||
if ! dpkg -s libglfw3-dev &>/dev/null 2>&1; then
|
||||
MISSING+=(libglfw3-dev)
|
||||
else
|
||||
echo " libglfw3-dev: installed"
|
||||
fi
|
||||
|
||||
if ! dpkg -s libgl1-mesa-dev &>/dev/null 2>&1; then
|
||||
MISSING+=(libgl1-mesa-dev)
|
||||
else
|
||||
echo " libgl1-mesa-dev: installed"
|
||||
fi
|
||||
|
||||
# Optional: mingw for cross-compile
|
||||
if command -v x86_64-w64-mingw32-g++ &>/dev/null; then
|
||||
echo " mingw-w64: $(x86_64-w64-mingw32-g++ --version | head -1)"
|
||||
else
|
||||
echo " mingw-w64: not installed (optional, for Windows cross-compile)"
|
||||
fi
|
||||
|
||||
if [ ${#MISSING[@]} -eq 0 ]; then
|
||||
echo "[install_cpp_deps] All dependencies satisfied."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[install_cpp_deps] Missing packages: ${MISSING[*]}"
|
||||
echo "[install_cpp_deps] Installing..."
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq "${MISSING[@]}"
|
||||
echo "[install_cpp_deps] Done."
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: install_mantine
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "install_mantine(project_dir: string) -> void"
|
||||
description: "Instala Mantine UI con todas sus dependencias (@mantine/core, hooks, charts, notifications, form) y PostCSS en un proyecto frontend. Detecta package manager por lockfile. Genera postcss.config.cjs si no existe. Idempotente."
|
||||
tags: [mantine, frontend, install, react, ui, postcss]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: project_dir
|
||||
desc: "directorio del proyecto frontend con package.json"
|
||||
output: "sin salida; muestra progreso de instalación"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/install_mantine.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Instalar Mantine en un proyecto con pnpm
|
||||
source install_mantine.sh
|
||||
install_mantine ./apps/rapid_dashboards/frontend
|
||||
|
||||
# Uso directo
|
||||
bash install_mantine.sh ./frontend
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Detecta el package manager por lockfile: pnpm-lock.yaml → pnpm, yarn.lock → yarn, package-lock.json → npm. Instala las dependencias core de Mantine v7+ y el stack PostCSS necesario. Si postcss.config.cjs ya existe no lo sobreescribe.
|
||||
@@ -0,0 +1,97 @@
|
||||
# install_mantine
|
||||
# ---------------
|
||||
# Instala dependencias de Mantine UI en un proyecto frontend.
|
||||
# Detecta package manager por lockfile (pnpm > yarn > npm).
|
||||
# Genera postcss.config.cjs si no existe.
|
||||
# Idempotente: no reinstala si ya estan presentes.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source install_mantine.sh
|
||||
# install_mantine /path/to/frontend
|
||||
#
|
||||
# USO (directo):
|
||||
# bash install_mantine.sh /path/to/frontend
|
||||
|
||||
install_mantine() {
|
||||
local project_dir="$1"
|
||||
|
||||
if [ -z "$project_dir" ]; then
|
||||
echo "install_mantine: se requiere project_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$project_dir/package.json" ]; then
|
||||
echo "install_mantine: no existe package.json en $project_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Detectar package manager
|
||||
local pm="npm"
|
||||
local add_cmd="install"
|
||||
local add_dev_flag="--save-dev"
|
||||
if [ -f "$project_dir/pnpm-lock.yaml" ] || [ -f "$project_dir/pnpm-workspace.yaml" ]; then
|
||||
pm="pnpm"
|
||||
add_cmd="add"
|
||||
add_dev_flag="-D"
|
||||
elif [ -f "$project_dir/yarn.lock" ]; then
|
||||
pm="yarn"
|
||||
add_cmd="add"
|
||||
add_dev_flag="--dev"
|
||||
elif [ -f "$project_dir/package-lock.json" ]; then
|
||||
pm="npm"
|
||||
add_cmd="install"
|
||||
add_dev_flag="--save-dev"
|
||||
fi
|
||||
|
||||
echo "Detectado package manager: $pm"
|
||||
|
||||
# Dependencias runtime
|
||||
local runtime_deps="@mantine/core @mantine/hooks @mantine/charts @mantine/notifications @mantine/form"
|
||||
echo "Instalando dependencias Mantine..."
|
||||
(cd "$project_dir" && $pm $add_cmd $runtime_deps 2>&1)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "install_mantine: fallo instalando dependencias runtime" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Dependencias PostCSS (dev)
|
||||
local dev_deps="postcss postcss-preset-mantine postcss-simple-vars"
|
||||
echo "Instalando dependencias PostCSS..."
|
||||
(cd "$project_dir" && $pm $add_cmd $add_dev_flag $dev_deps 2>&1)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "install_mantine: fallo instalando dependencias PostCSS" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Generar postcss.config.cjs si no existe
|
||||
if [ ! -f "$project_dir/postcss.config.cjs" ]; then
|
||||
echo "Generando postcss.config.cjs..."
|
||||
cat > "$project_dir/postcss.config.cjs" << 'POSTCSS'
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
POSTCSS
|
||||
echo "postcss.config.cjs creado"
|
||||
else
|
||||
echo "postcss.config.cjs ya existe, no se sobreescribe"
|
||||
fi
|
||||
|
||||
echo "Mantine instalado correctamente en $project_dir"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
install_mantine "$@"
|
||||
fi
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: capacitor_build_apk
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "capacitor_build_apk(web_app_dir: string, [app_id: string], [app_name: string]) -> void"
|
||||
description: "Pipeline que convierte una web app en un APK de Android usando Capacitor. Valida el entorno (ANDROID_HOME, Java 17+), construye el bundle web si no existe dist/, inicializa Capacitor si no está configurado, añade la plataforma Android, sincroniza y compila el APK con Gradle. El APK final queda en el directorio raíz de la web app."
|
||||
tags: [android, apk, capacitor, mobile, build, pipeline, bash]
|
||||
uses_functions:
|
||||
- install_android_sdk_bash_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: web_app_dir
|
||||
desc: "directorio raíz de la web app; debe contener package.json; si no existe dist/ se ejecuta pnpm build automáticamente"
|
||||
- name: app_id
|
||||
desc: "identificador de la app Android en formato reverse-DNS (default: com.fnregistry.app)"
|
||||
- name: app_name
|
||||
desc: "nombre visible de la app Android; si se omite, se lee del campo name de package.json"
|
||||
output: "APK de debug en <web_app_dir>/<app_name>.apk; imprime ruta y tamaño en MB al finalizar"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/pipelines/capacitor_build_apk.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Build con defaults (app-id y app-name desde package.json)
|
||||
./bash/functions/pipelines/capacitor_build_apk.sh ~/projects/my-web-app
|
||||
|
||||
# Build especificando app-id y app-name
|
||||
./bash/functions/pipelines/capacitor_build_apk.sh ~/projects/my-web-app \
|
||||
--app-id com.miempresa.miapp \
|
||||
--app-name "Mi Aplicación"
|
||||
```
|
||||
|
||||
## Flujo
|
||||
|
||||
1. **Validación** — verifica que `web_app_dir` existe, tiene `package.json`, que `ANDROID_HOME` está seteado (o sourcea `$HOME/android-sdk/env.sh`) y que Java 17+ está disponible.
|
||||
2. **Build web** — si no existe `dist/`, ejecuta `pnpm build` en el directorio de la app.
|
||||
3. **Init Capacitor** — si no existe `capacitor.config.ts`, instala `@capacitor/core`, `@capacitor/cli` y `@capacitor/android` via npm y genera el archivo de configuración con el `appId`, `appName` y `webDir: dist`.
|
||||
4. **Add Android** — si no existe el directorio `android/`, ejecuta `npx cap add android`.
|
||||
5. **Sync** — ejecuta `npx cap sync android` para copiar los assets web al proyecto Android.
|
||||
6. **Build APK** — ejecuta `./gradlew assembleDebug` desde `android/`; si falla sale con exit 1.
|
||||
7. **Copia APK** — copia `android/app/build/outputs/apk/debug/app-debug.apk` a `<web_app_dir>/<app_name>.apk`.
|
||||
8. **Resultado** — imprime la ruta del APK y su tamaño en MB.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- **Node.js** y **pnpm** disponibles en PATH
|
||||
- **Java 17+** disponible en PATH
|
||||
- **Android SDK** instalado: `ANDROID_HOME` seteado, o bien `$HOME/android-sdk/env.sh` existente (generado por `install_android_sdk`)
|
||||
- **Gradle wrapper** presente en el directorio `android/` (generado por `cap add android`)
|
||||
|
||||
## Notas
|
||||
|
||||
El pipeline usa `set -euo pipefail` — cualquier fallo detiene la ejecución inmediatamente.
|
||||
|
||||
El APK generado es un **debug build**, apto para desarrollo y pruebas. Para publicar en Play Store se necesita un release build firmado (`assembleRelease` con un keystore).
|
||||
|
||||
`install_android_sdk_bash_infra` se referencia como dependencia previa: el usuario debe haberlo ejecutado (o haber instalado el SDK manualmente) antes de invocar este pipeline.
|
||||
|
||||
La detección del `app_name` desde `package.json` usa `node -e` inline, lo que requiere que Node.js esté disponible. Si el campo `name` no existe en el JSON, se usa el valor por defecto `app`.
|
||||
|
||||
Para instalar el APK en un dispositivo Android conectado por USB (con depuración USB activada):
|
||||
|
||||
```bash
|
||||
adb install <web_app_dir>/<app_name>.apk
|
||||
```
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env bash
|
||||
# capacitor_build_apk
|
||||
# -------------------
|
||||
# Pipeline que convierte una web app buildeada en un APK de Android usando Capacitor.
|
||||
# Asume que el Android SDK está instalado (via install_android_sdk o manualmente).
|
||||
#
|
||||
# USO:
|
||||
# ./capacitor_build_apk.sh <web_app_dir> [--app-id com.example.app] [--app-name "My App"]
|
||||
#
|
||||
# ARGUMENTOS:
|
||||
# web_app_dir Directorio de la web app (debe contener package.json)
|
||||
# --app-id ID de la app Android (default: com.fnregistry.app)
|
||||
# --app-name Nombre visible de la app (default: name de package.json)
|
||||
#
|
||||
# REQUISITOS:
|
||||
# - Node.js + pnpm instalados en PATH
|
||||
# - Java 17+ instalado en PATH
|
||||
# - Android SDK: ANDROID_HOME seteado o $HOME/android-sdk/env.sh disponible
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parseo de argumentos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WEB_APP_DIR=""
|
||||
APP_ID="com.fnregistry.app"
|
||||
APP_NAME=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app-id)
|
||||
APP_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
--app-name)
|
||||
APP_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
-*)
|
||||
echo "[capacitor_build_apk] ERROR: argumento desconocido: $1" >&2
|
||||
echo "USO: $0 <web_app_dir> [--app-id com.example.app] [--app-name \"My App\"]" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
WEB_APP_DIR="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$WEB_APP_DIR" ]]; then
|
||||
echo "[capacitor_build_apk] ERROR: web_app_dir es obligatorio." >&2
|
||||
echo "USO: $0 <web_app_dir> [--app-id com.example.app] [--app-name \"My App\"]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Validación
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "[capacitor_build_apk] Validando entorno..."
|
||||
|
||||
# Verificar que web_app_dir existe y tiene package.json
|
||||
if [[ ! -d "$WEB_APP_DIR" ]]; then
|
||||
echo "[capacitor_build_apk] ERROR: directorio no existe: $WEB_APP_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$WEB_APP_DIR/package.json" ]]; then
|
||||
echo "[capacitor_build_apk] ERROR: no se encontró package.json en $WEB_APP_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolver app name desde package.json si no se pasó
|
||||
if [[ -z "$APP_NAME" ]]; then
|
||||
APP_NAME=$(node -e "const p = require('$WEB_APP_DIR/package.json'); process.stdout.write(p.name || 'app');" 2>/dev/null || echo "app")
|
||||
echo "[capacitor_build_apk] App name detectado desde package.json: $APP_NAME"
|
||||
fi
|
||||
|
||||
# Verificar ANDROID_HOME o sourcea env.sh
|
||||
if [[ -z "${ANDROID_HOME:-}" ]]; then
|
||||
ANDROID_ENV="$HOME/android-sdk/env.sh"
|
||||
if [[ -f "$ANDROID_ENV" ]]; then
|
||||
echo "[capacitor_build_apk] ANDROID_HOME no seteado, sourceando $ANDROID_ENV ..."
|
||||
# shellcheck source=/dev/null
|
||||
source "$ANDROID_ENV"
|
||||
else
|
||||
echo "[capacitor_build_apk] ERROR: ANDROID_HOME no está seteado y no se encontró $ANDROID_ENV" >&2
|
||||
echo " Instala el SDK con install_android_sdk o setea ANDROID_HOME manualmente." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[capacitor_build_apk] ANDROID_HOME: $ANDROID_HOME"
|
||||
|
||||
# Verificar Java 17+
|
||||
if ! command -v java &>/dev/null; then
|
||||
echo "[capacitor_build_apk] ERROR: java no está en PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
JAVA_VERSION=$(java -version 2>&1 | head -1 | grep -oP '(?<=version ")([0-9]+)' | head -1 || echo "0")
|
||||
if [[ "$JAVA_VERSION" -lt 17 ]]; then
|
||||
echo "[capacitor_build_apk] ERROR: se requiere Java 17+. Versión detectada: $JAVA_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[capacitor_build_apk] Java $JAVA_VERSION detectado."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Build web (si no existe dist/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [[ ! -d "$WEB_APP_DIR/dist" ]]; then
|
||||
echo "[capacitor_build_apk] No se encontró dist/, ejecutando pnpm build..."
|
||||
(cd "$WEB_APP_DIR" && pnpm build)
|
||||
echo "[capacitor_build_apk] Build web completado."
|
||||
else
|
||||
echo "[capacitor_build_apk] dist/ ya existe, omitiendo build web."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Init Capacitor (si no existe capacitor.config.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [[ ! -f "$WEB_APP_DIR/capacitor.config.ts" ]]; then
|
||||
echo "[capacitor_build_apk] Instalando dependencias de Capacitor..."
|
||||
(cd "$WEB_APP_DIR" && npm install @capacitor/core @capacitor/cli @capacitor/android)
|
||||
|
||||
echo "[capacitor_build_apk] Generando capacitor.config.ts..."
|
||||
cat > "$WEB_APP_DIR/capacitor.config.ts" <<CAPCONFIG
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: '${APP_ID}',
|
||||
appName: '${APP_NAME}',
|
||||
webDir: 'dist',
|
||||
server: { androidScheme: 'https' }
|
||||
};
|
||||
|
||||
export default config;
|
||||
CAPCONFIG
|
||||
|
||||
echo "[capacitor_build_apk] capacitor.config.ts generado."
|
||||
else
|
||||
echo "[capacitor_build_apk] capacitor.config.ts ya existe, omitiendo init."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Add Android (si no existe el directorio android/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [[ ! -d "$WEB_APP_DIR/android" ]]; then
|
||||
echo "[capacitor_build_apk] Añadiendo plataforma Android..."
|
||||
(cd "$WEB_APP_DIR" && npx cap add android)
|
||||
echo "[capacitor_build_apk] Plataforma Android añadida."
|
||||
else
|
||||
echo "[capacitor_build_apk] Directorio android/ ya existe, omitiendo cap add."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "[capacitor_build_apk] Sincronizando assets web con Android..."
|
||||
(cd "$WEB_APP_DIR" && npx cap sync android)
|
||||
echo "[capacitor_build_apk] Sync completado."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Build APK
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "[capacitor_build_apk] Compilando APK con Gradle..."
|
||||
if ! (cd "$WEB_APP_DIR/android" && ./gradlew assembleDebug); then
|
||||
echo "[capacitor_build_apk] ERROR: Gradle falló. Revisa los logs anteriores." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APK_SOURCE="$WEB_APP_DIR/android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
|
||||
if [[ ! -f "$APK_SOURCE" ]]; then
|
||||
echo "[capacitor_build_apk] ERROR: Gradle terminó sin error pero no se encontró el APK en $APK_SOURCE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Copia APK al directorio raíz
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
APK_DEST="$WEB_APP_DIR/${APP_NAME}.apk"
|
||||
cp "$APK_SOURCE" "$APK_DEST"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Resultado
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
APK_SIZE_BYTES=$(stat -c%s "$APK_DEST" 2>/dev/null || stat -f%z "$APK_DEST" 2>/dev/null || echo "0")
|
||||
APK_SIZE_MB=$(awk "BEGIN {printf \"%.1f\", $APK_SIZE_BYTES/1048576}")
|
||||
|
||||
echo ""
|
||||
echo "---------------------------------------------------------------------"
|
||||
echo "APK generado: $APK_DEST"
|
||||
echo "Tamaño: ${APK_SIZE_MB} MB"
|
||||
echo ""
|
||||
echo "Para instalar en un dispositivo conectado por USB:"
|
||||
echo " adb install '$APK_DEST'"
|
||||
echo "---------------------------------------------------------------------"
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: gitea_init_app
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "gitea_init_app(directory: string, owner: string, name: string, private: string) -> string"
|
||||
description: "Pipeline que crea un repositorio en Gitea, sube el directorio local y añade a egutierrez como colaborador admin. Compone gitea_create_repo → gitea_push_directory → gitea_add_collaborator."
|
||||
tags: [gitea, git, pipeline, repo, create, push, launcher, infra]
|
||||
uses_functions:
|
||||
- gitea_create_repo_bash_infra
|
||||
- gitea_push_directory_bash_infra
|
||||
- gitea_add_collaborator_bash_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: directory
|
||||
desc: "ruta al directorio local a subir como repositorio"
|
||||
- name: owner
|
||||
desc: "usuario u organización en Gitea que será propietaria del repo"
|
||||
- name: name
|
||||
desc: "nombre del repositorio (opcional: se infiere del basename del directorio)"
|
||||
- name: private
|
||||
desc: "si el repo debe ser privado, 'true' o 'false' (default: false)"
|
||||
output: "URL del repositorio creado en Gitea"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/pipelines/gitea_init_app.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
export GITEA_URL="$(pass agentes/gitea-url)"
|
||||
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||
|
||||
# Crear repo con nombre inferido del directorio
|
||||
bash bash/functions/pipelines/gitea_init_app.sh /home/lucas/myapp myorg
|
||||
|
||||
# Nombre explícito y repo privado
|
||||
bash bash/functions/pipelines/gitea_init_app.sh /home/lucas/myapp myorg my-custom-name true
|
||||
|
||||
# Con flags
|
||||
bash bash/functions/pipelines/gitea_init_app.sh \
|
||||
--directory /home/lucas/myapp \
|
||||
--owner myorg \
|
||||
--name my-app \
|
||||
--private true
|
||||
```
|
||||
|
||||
## Pasos del pipeline
|
||||
|
||||
1. `gitea_create_repo owner name private` — crea el repo (idempotente si ya existe)
|
||||
2. `gitea_push_directory directory owner repo` — inicializa git y hace push del directorio
|
||||
3. `gitea_add_collaborator owner repo egutierrez admin` — añade colaborador con permisos admin
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
|
||||
- Si el repo ya existe (409), el pipeline continúa con el push y añade el colaborador.
|
||||
- El colaborador `egutierrez` es fijo en el pipeline — para variarlo usar las funciones individuales.
|
||||
- La URL del repo se imprime a stdout al finalizar.
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pipeline: gitea_init_app — Crea repo en Gitea, sube directorio y añade colaborador egutierrez
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../infra/gitea_create_repo.sh"
|
||||
source "$SCRIPT_DIR/../infra/gitea_push_directory.sh"
|
||||
source "$SCRIPT_DIR/../infra/gitea_add_collaborator.sh"
|
||||
|
||||
main() {
|
||||
local directory=""
|
||||
local owner=""
|
||||
local name=""
|
||||
local private="false"
|
||||
|
||||
# Parsear argumentos
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--directory) directory="$2"; shift 2 ;;
|
||||
--owner) owner="$2"; shift 2 ;;
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--private) private="$2"; shift 2 ;;
|
||||
*)
|
||||
# Argumentos posicionales: directory owner [name] [private]
|
||||
if [[ -z "$directory" ]]; then
|
||||
directory="$1"
|
||||
elif [[ -z "$owner" ]]; then
|
||||
owner="$1"
|
||||
elif [[ -z "$name" ]]; then
|
||||
name="$1"
|
||||
else
|
||||
private="$1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$directory" || -z "$owner" ]]; then
|
||||
echo "gitea_init_app: uso: gitea_init_app <directory> <owner> [name] [private]" >&2
|
||||
echo "gitea_init_app: o con flags: --directory <dir> --owner <owner> [--name <name>] [--private true]" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Inferir nombre del repo desde basename del directorio si no se especificó
|
||||
if [[ -z "$name" ]]; then
|
||||
name=$(basename "$directory")
|
||||
echo "gitea_init_app: nombre inferido del directorio: '$name'" >&2
|
||||
fi
|
||||
|
||||
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||
echo "gitea_init_app: GITEA_URL no está seteada" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "gitea_init_app: GITEA_TOKEN no está seteado" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "gitea_init_app: iniciando pipeline para '$owner/$name'..." >&2
|
||||
echo "gitea_init_app: directorio fuente: '$directory'" >&2
|
||||
|
||||
# Paso 1: Crear repo
|
||||
echo "gitea_init_app: [1/3] creando repositorio..." >&2
|
||||
gitea_create_repo "$owner" "$name" "$private" "" > /dev/null
|
||||
|
||||
# Paso 2: Subir directorio
|
||||
echo "gitea_init_app: [2/3] subiendo directorio al repositorio..." >&2
|
||||
gitea_push_directory "$directory" "$owner" "$name"
|
||||
|
||||
# Paso 3: Añadir colaborador egutierrez con permisos admin
|
||||
echo "gitea_init_app: [3/3] añadiendo colaborador egutierrez..." >&2
|
||||
gitea_add_collaborator "$owner" "$name" "egutierrez" "admin"
|
||||
|
||||
echo "gitea_init_app: pipeline completado — ${GITEA_URL}/${owner}/${name}" >&2
|
||||
echo "${GITEA_URL}/${owner}/${name}"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"fn-registry/registry"
|
||||
)
|
||||
|
||||
func buildCppCommand(fn *registry.Function, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
|
||||
cppRoot := filepath.Join(registryRoot, "cpp")
|
||||
buildDir := filepath.Join(cppRoot, "build", "linux")
|
||||
|
||||
// Ensure build directory exists and cmake is configured
|
||||
if _, err := os.Stat(filepath.Join(buildDir, "CMakeCache.txt")); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "[fn run] configuring cmake for cpp...\n")
|
||||
configure := exec.Command("cmake", "-B", buildDir, "-S", cppRoot)
|
||||
configure.Dir = registryRoot
|
||||
configure.Stdout = os.Stderr
|
||||
configure.Stderr = os.Stderr
|
||||
if err := configure.Run(); err != nil {
|
||||
return nil, fmt.Errorf("cmake configure failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
dir := filepath.Dir(absPath)
|
||||
|
||||
// Check if the function's directory has its own CMakeLists.txt (app with main)
|
||||
localCMake := filepath.Join(dir, "CMakeLists.txt")
|
||||
hasMain := false
|
||||
if _, err := os.Stat(localCMake); err == nil {
|
||||
hasMain = true
|
||||
}
|
||||
// Also check for main.cpp in the same directory
|
||||
mainCpp := filepath.Join(dir, "main.cpp")
|
||||
if _, err := os.Stat(mainCpp); err == nil {
|
||||
hasMain = true
|
||||
}
|
||||
|
||||
if hasMain {
|
||||
// Build and run the app binary
|
||||
targetName := filepath.Base(dir)
|
||||
build := exec.Command("cmake", "--build", buildDir, "--target", targetName)
|
||||
build.Dir = registryRoot
|
||||
build.Stdout = os.Stderr
|
||||
build.Stderr = os.Stderr
|
||||
fmt.Fprintf(os.Stderr, "[fn run] building target %s...\n", targetName)
|
||||
if err := build.Run(); err != nil {
|
||||
return nil, fmt.Errorf("cmake build failed: %w", err)
|
||||
}
|
||||
|
||||
// Find the built binary
|
||||
binaryPath := findBinary(buildDir, targetName)
|
||||
if binaryPath == "" {
|
||||
return nil, fmt.Errorf("built binary %q not found in %s", targetName, buildDir)
|
||||
}
|
||||
|
||||
cmd := exec.Command(binaryPath, args...)
|
||||
cmd.Dir = dir
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// Library code: compile-check only (like go vet)
|
||||
build := exec.Command("cmake", "--build", buildDir)
|
||||
build.Dir = registryRoot
|
||||
build.Stdout = os.Stderr
|
||||
build.Stderr = os.Stderr
|
||||
fmt.Fprintf(os.Stderr, "[fn run] %s is library code — running compile check\n", fn.ID)
|
||||
|
||||
if err := build.Run(); err != nil {
|
||||
return nil, fmt.Errorf("compile check failed: %w", err)
|
||||
}
|
||||
|
||||
// Return a no-op command that just prints success
|
||||
cmd := exec.Command("echo", fmt.Sprintf("[fn run] %s compiled successfully", fn.ID))
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// findBinary searches for an executable in the build tree.
|
||||
func findBinary(buildDir, name string) string {
|
||||
// Common locations cmake puts binaries
|
||||
candidates := []string{
|
||||
filepath.Join(buildDir, name),
|
||||
filepath.Join(buildDir, "apps", name, name),
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
if info, err := os.Stat(c); err == nil && !info.IsDir() {
|
||||
// Check if executable
|
||||
if info.Mode()&0111 != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the build directory as fallback
|
||||
var found string
|
||||
filepath.Walk(buildDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info.Name() == name && info.Mode()&0111 != 0 {
|
||||
found = path
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Also try without extension match for paths with subdirectories
|
||||
if found == "" {
|
||||
filepath.Walk(buildDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
base := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
|
||||
if base == name && info.Mode()&0111 != 0 {
|
||||
found = path
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
@@ -103,6 +103,8 @@ func buildCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath
|
||||
return buildBashCommand(absPath, args)
|
||||
case "ts":
|
||||
return buildTsCommand(registryRoot, absPath, args)
|
||||
case "cpp":
|
||||
return buildCppCommand(fn, registryRoot, absPath, args)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported lang %q for execution", fn.Lang)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(fn_registry_cpp LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
# --- Options ---
|
||||
option(TRACY_ENABLE "Enable Tracy profiling" OFF)
|
||||
|
||||
# --- Vendor: Dear ImGui ---
|
||||
set(IMGUI_DIR ${CMAKE_CURRENT_SOURCE_DIR}/vendor/imgui)
|
||||
add_library(imgui STATIC
|
||||
${IMGUI_DIR}/imgui.cpp
|
||||
${IMGUI_DIR}/imgui_draw.cpp
|
||||
${IMGUI_DIR}/imgui_tables.cpp
|
||||
${IMGUI_DIR}/imgui_widgets.cpp
|
||||
${IMGUI_DIR}/imgui_demo.cpp
|
||||
${IMGUI_DIR}/backends/imgui_impl_glfw.cpp
|
||||
${IMGUI_DIR}/backends/imgui_impl_opengl3.cpp
|
||||
)
|
||||
target_include_directories(imgui PUBLIC
|
||||
${IMGUI_DIR}
|
||||
${IMGUI_DIR}/backends
|
||||
)
|
||||
|
||||
# --- Vendor: ImPlot ---
|
||||
set(IMPLOT_DIR ${CMAKE_CURRENT_SOURCE_DIR}/vendor/implot)
|
||||
add_library(implot STATIC
|
||||
${IMPLOT_DIR}/implot.cpp
|
||||
${IMPLOT_DIR}/implot_items.cpp
|
||||
)
|
||||
target_include_directories(implot PUBLIC ${IMPLOT_DIR})
|
||||
target_link_libraries(implot PUBLIC imgui)
|
||||
|
||||
# --- Vendor: Tracy (optional) ---
|
||||
if(TRACY_ENABLE)
|
||||
set(TRACY_DIR ${CMAKE_CURRENT_SOURCE_DIR}/vendor/tracy)
|
||||
add_library(tracy STATIC
|
||||
${TRACY_DIR}/public/TracyClient.cpp
|
||||
)
|
||||
target_include_directories(tracy PUBLIC ${TRACY_DIR}/public)
|
||||
target_compile_definitions(tracy PUBLIC TRACY_ENABLE)
|
||||
endif()
|
||||
|
||||
# --- Platform dependencies ---
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
|
||||
# Cross-compile: use vendored or system GLFW, link opengl32/gdi32
|
||||
find_package(glfw3 QUIET)
|
||||
if(NOT glfw3_FOUND)
|
||||
# Build GLFW from source if available
|
||||
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/vendor/glfw/CMakeLists.txt)
|
||||
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)
|
||||
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
add_subdirectory(vendor/glfw)
|
||||
else()
|
||||
message(FATAL_ERROR "GLFW not found. For Windows cross-compile, add GLFW source to cpp/vendor/glfw/")
|
||||
endif()
|
||||
endif()
|
||||
set(PLATFORM_LIBS glfw opengl32 gdi32 imm32)
|
||||
else()
|
||||
# Linux native
|
||||
find_package(glfw3 REQUIRED)
|
||||
find_package(OpenGL REQUIRED)
|
||||
set(PLATFORM_LIBS glfw OpenGL::GL ${CMAKE_DL_LIBS})
|
||||
endif()
|
||||
|
||||
target_link_libraries(imgui PUBLIC ${PLATFORM_LIBS})
|
||||
|
||||
# --- Framework ---
|
||||
add_library(fn_framework STATIC
|
||||
framework/app_base.cpp
|
||||
)
|
||||
target_include_directories(fn_framework PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/framework
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/functions
|
||||
)
|
||||
target_link_libraries(fn_framework PUBLIC imgui implot)
|
||||
if(TRACY_ENABLE)
|
||||
target_link_libraries(fn_framework PUBLIC tracy)
|
||||
endif()
|
||||
|
||||
# --- Macro for creating ImGui apps ---
|
||||
function(add_imgui_app target)
|
||||
add_executable(${target} ${ARGN})
|
||||
target_link_libraries(${target} PRIVATE fn_framework)
|
||||
target_include_directories(${target} PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/functions
|
||||
)
|
||||
endfunction()
|
||||
|
||||
# --- Function libraries (headers for composition) ---
|
||||
# Functions are compiled as part of apps that use them via add_imgui_app.
|
||||
# Each function is a .h/.cpp pair included by the app's CMakeLists.txt.
|
||||
|
||||
# --- Demo app ---
|
||||
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/apps/chart_demo/CMakeLists.txt)
|
||||
add_subdirectory(apps/chart_demo)
|
||||
endif()
|
||||
@@ -0,0 +1,8 @@
|
||||
add_imgui_app(chart_demo
|
||||
main.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/line_plot.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/scatter_plot.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/bar_chart.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/heatmap.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/core/fps_overlay.cpp
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
#include "app_base.h"
|
||||
#include "imgui.h"
|
||||
#include "implot.h"
|
||||
|
||||
#include "viz/line_plot.h"
|
||||
#include "viz/scatter_plot.h"
|
||||
#include "viz/bar_chart.h"
|
||||
#include "viz/heatmap.h"
|
||||
#include "core/fps_overlay.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
// Generate sample data
|
||||
static constexpr int N = 500;
|
||||
static float xs[N], ys_sin[N], ys_cos[N];
|
||||
static float scatter_x[200], scatter_y[200];
|
||||
static const char* bar_labels[] = {"Go", "Python", "Bash", "TypeScript", "C++"};
|
||||
static float bar_values[] = {201.0f, 202.0f, 38.0f, 80.0f, 5.0f};
|
||||
static float heat_data[10 * 10];
|
||||
|
||||
static bool data_initialized = false;
|
||||
|
||||
static void init_data() {
|
||||
if (data_initialized) return;
|
||||
for (int i = 0; i < N; i++) {
|
||||
xs[i] = static_cast<float>(i) * 0.02f;
|
||||
ys_sin[i] = sinf(xs[i]);
|
||||
ys_cos[i] = cosf(xs[i]);
|
||||
}
|
||||
for (int i = 0; i < 200; i++) {
|
||||
scatter_x[i] = static_cast<float>(rand()) / RAND_MAX * 10.0f;
|
||||
scatter_y[i] = scatter_x[i] * 0.5f + (static_cast<float>(rand()) / RAND_MAX - 0.5f) * 3.0f;
|
||||
}
|
||||
for (int i = 0; i < 100; i++) {
|
||||
int r = i / 10, c = i % 10;
|
||||
heat_data[i] = sinf(r * 0.5f) * cosf(c * 0.5f);
|
||||
}
|
||||
data_initialized = true;
|
||||
}
|
||||
|
||||
static void render() {
|
||||
init_data();
|
||||
fps_overlay();
|
||||
|
||||
// Full-window dockspace
|
||||
ImGui::DockSpaceOverViewport(0, ImGui::GetMainViewport());
|
||||
|
||||
if (ImGui::Begin("fn_registry — Chart Demo")) {
|
||||
if (ImGui::BeginTabBar("##charts")) {
|
||||
if (ImGui::BeginTabItem("Line Plot")) {
|
||||
ImGui::Text("sin(x) — %d points", N);
|
||||
line_plot("Sine Wave", xs, ys_sin, N);
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
if (ImGui::BeginTabItem("Scatter Plot")) {
|
||||
ImGui::Text("y = 0.5x + noise — 200 points");
|
||||
scatter_plot("Scatter Data", scatter_x, scatter_y, 200);
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
if (ImGui::BeginTabItem("Bar Chart")) {
|
||||
ImGui::Text("Functions per language in fn_registry");
|
||||
bar_chart("Registry Languages", bar_labels, bar_values, 5);
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
if (ImGui::BeginTabItem("Heatmap")) {
|
||||
ImGui::Text("sin(r) * cos(c) — 10x10 matrix");
|
||||
heatmap("Correlation Matrix", heat_data, 10, 10, -1.0f, 1.0f);
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
ImGui::EndTabBar();
|
||||
}
|
||||
}
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
int main() {
|
||||
return fn::run_app({.title = "fn_registry — Chart Demo", .width = 1400, .height = 900}, render);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#include "app_base.h"
|
||||
|
||||
#include "imgui.h"
|
||||
#include "imgui_impl_glfw.h"
|
||||
#include "imgui_impl_opengl3.h"
|
||||
#include "implot.h"
|
||||
|
||||
#include <GLFW/glfw3.h>
|
||||
#include <cstdio>
|
||||
|
||||
#ifdef TRACY_ENABLE
|
||||
#include "tracy/Tracy.hpp"
|
||||
#endif
|
||||
|
||||
static void glfw_error_callback(int error, const char* description) {
|
||||
fprintf(stderr, "GLFW Error %d: %s\n", error, description);
|
||||
}
|
||||
|
||||
namespace fn {
|
||||
|
||||
int run_app(AppConfig config, std::function<void()> render_fn) {
|
||||
glfwSetErrorCallback(glfw_error_callback);
|
||||
if (!glfwInit()) {
|
||||
fprintf(stderr, "Failed to initialize GLFW\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// OpenGL 3.3 core
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
||||
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
||||
#ifdef __APPLE__
|
||||
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
|
||||
#endif
|
||||
|
||||
GLFWwindow* window = glfwCreateWindow(config.width, config.height, config.title, nullptr, nullptr);
|
||||
if (!window) {
|
||||
fprintf(stderr, "Failed to create GLFW window\n");
|
||||
glfwTerminate();
|
||||
return 1;
|
||||
}
|
||||
|
||||
glfwMakeContextCurrent(window);
|
||||
glfwSwapInterval(config.vsync ? 1 : 0);
|
||||
|
||||
// Setup ImGui
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGui::CreateContext();
|
||||
ImPlot::CreateContext();
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
|
||||
|
||||
ImGui::StyleColorsDark();
|
||||
|
||||
ImGui_ImplGlfw_InitForOpenGL(window, true);
|
||||
ImGui_ImplOpenGL3_Init("#version 330");
|
||||
|
||||
// Main loop
|
||||
while (!glfwWindowShouldClose(window)) {
|
||||
glfwPollEvents();
|
||||
|
||||
if (glfwGetWindowAttrib(window, GLFW_ICONIFIED)) {
|
||||
glfwWaitEvents();
|
||||
continue;
|
||||
}
|
||||
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
ImGui_ImplGlfw_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
|
||||
render_fn();
|
||||
|
||||
ImGui::Render();
|
||||
int display_w, display_h;
|
||||
glfwGetFramebufferSize(window, &display_w, &display_h);
|
||||
glViewport(0, 0, display_w, display_h);
|
||||
glClearColor(config.bg_r, config.bg_g, config.bg_b, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
||||
|
||||
glfwSwapBuffers(window);
|
||||
|
||||
#ifdef TRACY_ENABLE
|
||||
FrameMark;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
ImGui_ImplOpenGL3_Shutdown();
|
||||
ImGui_ImplGlfw_Shutdown();
|
||||
ImPlot::DestroyContext();
|
||||
ImGui::DestroyContext();
|
||||
glfwDestroyWindow(window);
|
||||
glfwTerminate();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int run_app(std::function<void()> render_fn) {
|
||||
return run_app(AppConfig{}, render_fn);
|
||||
}
|
||||
|
||||
} // namespace fn
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
namespace fn {
|
||||
|
||||
struct AppConfig {
|
||||
const char* title = "fn_registry";
|
||||
int width = 1280;
|
||||
int height = 720;
|
||||
bool vsync = true;
|
||||
float bg_r = 0.1f;
|
||||
float bg_g = 0.1f;
|
||||
float bg_b = 0.1f;
|
||||
};
|
||||
|
||||
// Run an ImGui application. The render_fn is called every frame
|
||||
// between ImGui::NewFrame() and ImGui::Render().
|
||||
// Returns 0 on clean exit, 1 on error.
|
||||
int run_app(AppConfig config, std::function<void()> render_fn);
|
||||
|
||||
// Convenience: run with default config
|
||||
int run_app(std::function<void()> render_fn);
|
||||
|
||||
} // namespace fn
|
||||
@@ -0,0 +1,33 @@
|
||||
#include "core/fps_overlay.h"
|
||||
#include "imgui.h"
|
||||
|
||||
#ifdef TRACY_ENABLE
|
||||
#include "tracy/Tracy.hpp"
|
||||
#endif
|
||||
|
||||
void fps_overlay() {
|
||||
#ifdef TRACY_ENABLE
|
||||
ZoneScoped;
|
||||
#endif
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration
|
||||
| ImGuiWindowFlags_AlwaysAutoResize
|
||||
| ImGuiWindowFlags_NoSavedSettings
|
||||
| ImGuiWindowFlags_NoFocusOnAppearing
|
||||
| ImGuiWindowFlags_NoNav
|
||||
| ImGuiWindowFlags_NoMove;
|
||||
|
||||
const float pad = 10.0f;
|
||||
const ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImVec2 pos(viewport->WorkPos.x + viewport->WorkSize.x - pad,
|
||||
viewport->WorkPos.y + pad);
|
||||
ImGui::SetNextWindowPos(pos, ImGuiCond_Always, ImVec2(1.0f, 0.0f));
|
||||
ImGui::SetNextWindowBgAlpha(0.65f);
|
||||
|
||||
if (ImGui::Begin("##fps_overlay", nullptr, flags)) {
|
||||
ImGui::Text("%.1f FPS", io.Framerate);
|
||||
ImGui::Text("%.3f ms", 1000.0f / io.Framerate);
|
||||
}
|
||||
ImGui::End();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
// Renders an FPS counter overlay in the top-right corner.
|
||||
// Call within an ImGui frame.
|
||||
void fps_overlay();
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: fps_overlay
|
||||
kind: component
|
||||
lang: cpp
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "void fps_overlay()"
|
||||
description: "Renderiza un overlay de FPS y frametime en la esquina superior derecha, con soporte opcional de Tracy"
|
||||
tags: [imgui, fps, overlay, profiling, debug]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [imgui]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/core/fps_overlay.cpp"
|
||||
framework: imgui
|
||||
params: []
|
||||
output: "Renderiza el overlay de FPS en el frame ImGui actual"
|
||||
---
|
||||
|
||||
# fps_overlay
|
||||
|
||||
Muestra FPS y frametime (ms) en una ventana semi-transparente en la esquina superior derecha.
|
||||
|
||||
Si se compila con `TRACY_ENABLE`, incluye un `ZoneScoped` para profiling con Tracy.
|
||||
@@ -0,0 +1,26 @@
|
||||
#include "viz/bar_chart.h"
|
||||
#include "implot.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
void bar_chart(const char* title, const char* const* labels, const float* values, int count, float bar_width) {
|
||||
if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
|
||||
std::vector<double> positions(count);
|
||||
for (int i = 0; i < count; i++) positions[i] = i;
|
||||
|
||||
ImPlot::SetupAxisTicks(ImAxis_X1, positions.data(), count, labels);
|
||||
ImPlot::PlotBars("##data", values, count, bar_width);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
}
|
||||
|
||||
void bar_chart(const char* title, const char* const* labels, const double* values, int count, double bar_width) {
|
||||
if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
|
||||
std::vector<double> positions(count);
|
||||
for (int i = 0; i < count; i++) positions[i] = i;
|
||||
|
||||
ImPlot::SetupAxisTicks(ImAxis_X1, positions.data(), count, labels);
|
||||
ImPlot::PlotBars("##data", values, count, bar_width);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
// Renders a vertical bar chart using ImPlot.
|
||||
// Call within an ImGui frame.
|
||||
void bar_chart(const char* title, const char* const* labels, const float* values, int count, float bar_width = 0.67f);
|
||||
void bar_chart(const char* title, const char* const* labels, const double* values, int count, double bar_width = 0.67);
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: bar_chart
|
||||
kind: component
|
||||
lang: cpp
|
||||
domain: viz
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "void bar_chart(const char* title, const char* const* labels, const float* values, int count, float bar_width)"
|
||||
description: "Renderiza un grafico de barras verticales usando ImPlot dentro de un frame ImGui"
|
||||
tags: [implot, chart, visualization, gpu, bar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [implot]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/viz/bar_chart.cpp"
|
||||
framework: imgui
|
||||
params:
|
||||
- name: title
|
||||
desc: "Titulo del grafico de barras"
|
||||
- name: labels
|
||||
desc: "Array de etiquetas para el eje X, una por barra"
|
||||
- name: values
|
||||
desc: "Array de valores numericos para la altura de cada barra"
|
||||
- name: count
|
||||
desc: "Numero de barras (longitud de labels y values)"
|
||||
- name: bar_width
|
||||
desc: "Ancho de cada barra como fraccion del espacio disponible (default 0.67)"
|
||||
output: "Renderiza el grafico de barras en el frame ImGui actual"
|
||||
---
|
||||
|
||||
# bar_chart
|
||||
|
||||
Wrapper atomico sobre `ImPlot::PlotBars` con configuracion automatica de etiquetas en el eje X.
|
||||
|
||||
Debe llamarse dentro del render callback de `fn::run_app`.
|
||||
@@ -0,0 +1,24 @@
|
||||
#include "viz/heatmap.h"
|
||||
#include "implot.h"
|
||||
|
||||
void heatmap(const char* title, const float* values, int rows, int cols,
|
||||
float scale_min, float scale_max) {
|
||||
if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_NoLegend)) {
|
||||
ImPlot::SetupAxes(nullptr, nullptr,
|
||||
ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations);
|
||||
ImPlot::PlotHeatmap("##data", values, rows, cols,
|
||||
scale_min, scale_max);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
}
|
||||
|
||||
void heatmap(const char* title, const double* values, int rows, int cols,
|
||||
double scale_min, double scale_max) {
|
||||
if (ImPlot::BeginPlot(title, ImVec2(-1, 0), ImPlotFlags_NoLegend)) {
|
||||
ImPlot::SetupAxes(nullptr, nullptr,
|
||||
ImPlotAxisFlags_NoDecorations, ImPlotAxisFlags_NoDecorations);
|
||||
ImPlot::PlotHeatmap("##data", values, rows, cols,
|
||||
scale_min, scale_max);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
// Renders a heatmap using ImPlot.
|
||||
// Data is row-major: values[row * cols + col].
|
||||
// Call within an ImGui frame.
|
||||
void heatmap(const char* title, const float* values, int rows, int cols,
|
||||
float scale_min = 0.0f, float scale_max = 0.0f);
|
||||
void heatmap(const char* title, const double* values, int rows, int cols,
|
||||
double scale_min = 0.0, double scale_max = 0.0);
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: heatmap
|
||||
kind: component
|
||||
lang: cpp
|
||||
domain: viz
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "void heatmap(const char* title, const float* values, int rows, int cols, float scale_min, float scale_max)"
|
||||
description: "Renderiza un mapa de calor 2D usando ImPlot dentro de un frame ImGui"
|
||||
tags: [implot, chart, visualization, gpu, heatmap, matrix]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [implot]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/viz/heatmap.cpp"
|
||||
framework: imgui
|
||||
params:
|
||||
- name: title
|
||||
desc: "Titulo del heatmap"
|
||||
- name: values
|
||||
desc: "Array de valores en orden row-major (values[row * cols + col])"
|
||||
- name: rows
|
||||
desc: "Numero de filas de la matriz"
|
||||
- name: cols
|
||||
desc: "Numero de columnas de la matriz"
|
||||
- name: scale_min
|
||||
desc: "Valor minimo de la escala de color (0 para autodetectar)"
|
||||
- name: scale_max
|
||||
desc: "Valor maximo de la escala de color (0 para autodetectar)"
|
||||
output: "Renderiza el heatmap en el frame ImGui actual"
|
||||
---
|
||||
|
||||
# heatmap
|
||||
|
||||
Wrapper atomico sobre `ImPlot::PlotHeatmap`. Renderiza una matriz de valores como mapa de calor con escala de color.
|
||||
|
||||
Los datos deben estar en formato row-major. Si `scale_min` y `scale_max` son ambos 0, ImPlot autodetecta el rango.
|
||||
@@ -0,0 +1,16 @@
|
||||
#include "viz/line_plot.h"
|
||||
#include "implot.h"
|
||||
|
||||
void line_plot(const char* title, const float* xs, const float* ys, int count) {
|
||||
if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
|
||||
ImPlot::PlotLine("##data", xs, ys, count);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
}
|
||||
|
||||
void line_plot(const char* title, const double* xs, const double* ys, int count) {
|
||||
if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
|
||||
ImPlot::PlotLine("##data", xs, ys, count);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// Renders a 2D line plot using ImPlot.
|
||||
// Call within an ImGui frame (inside fn::run_app render callback).
|
||||
void line_plot(const char* title, const float* xs, const float* ys, int count);
|
||||
|
||||
// Overload with double precision.
|
||||
void line_plot(const char* title, const double* xs, const double* ys, int count);
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: line_plot
|
||||
kind: component
|
||||
lang: cpp
|
||||
domain: viz
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "void line_plot(const char* title, const float* xs, const float* ys, int count)"
|
||||
description: "Renderiza un grafico de lineas 2D usando ImPlot dentro de un frame ImGui"
|
||||
tags: [implot, chart, visualization, gpu, line]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [implot]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/viz/line_plot.cpp"
|
||||
framework: imgui
|
||||
params:
|
||||
- name: title
|
||||
desc: "Titulo del grafico, se muestra como header del plot"
|
||||
- name: xs
|
||||
desc: "Array de coordenadas X"
|
||||
- name: ys
|
||||
desc: "Array de coordenadas Y"
|
||||
- name: count
|
||||
desc: "Numero de puntos en los arrays xs/ys"
|
||||
output: "Renderiza el grafico de lineas en el frame ImGui actual"
|
||||
---
|
||||
|
||||
# line_plot
|
||||
|
||||
Wrapper atomico sobre `ImPlot::PlotLine`. Renderiza un grafico de lineas 2D con los datos proporcionados.
|
||||
|
||||
Debe llamarse dentro del render callback de `fn::run_app` (o cualquier contexto con un frame ImGui activo).
|
||||
|
||||
Soporta `float` y `double` precision.
|
||||
@@ -0,0 +1,16 @@
|
||||
#include "viz/scatter_plot.h"
|
||||
#include "implot.h"
|
||||
|
||||
void scatter_plot(const char* title, const float* xs, const float* ys, int count) {
|
||||
if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
|
||||
ImPlot::PlotScatter("##data", xs, ys, count);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
}
|
||||
|
||||
void scatter_plot(const char* title, const double* xs, const double* ys, int count) {
|
||||
if (ImPlot::BeginPlot(title, ImVec2(-1, 0))) {
|
||||
ImPlot::PlotScatter("##data", xs, ys, count);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
// Renders a scatter plot using ImPlot.
|
||||
// Call within an ImGui frame.
|
||||
void scatter_plot(const char* title, const float* xs, const float* ys, int count);
|
||||
void scatter_plot(const char* title, const double* xs, const double* ys, int count);
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: scatter_plot
|
||||
kind: component
|
||||
lang: cpp
|
||||
domain: viz
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "void scatter_plot(const char* title, const float* xs, const float* ys, int count)"
|
||||
description: "Renderiza un grafico de dispersion usando ImPlot dentro de un frame ImGui"
|
||||
tags: [implot, chart, visualization, gpu, scatter]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [implot]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/viz/scatter_plot.cpp"
|
||||
framework: imgui
|
||||
params:
|
||||
- name: title
|
||||
desc: "Titulo del grafico scatter"
|
||||
- name: xs
|
||||
desc: "Array de coordenadas X"
|
||||
- name: ys
|
||||
desc: "Array de coordenadas Y"
|
||||
- name: count
|
||||
desc: "Numero de puntos en los arrays xs/ys"
|
||||
output: "Renderiza el grafico de dispersion en el frame ImGui actual"
|
||||
---
|
||||
|
||||
# scatter_plot
|
||||
|
||||
Wrapper atomico sobre `ImPlot::PlotScatter`. Renderiza un grafico de dispersion 2D.
|
||||
|
||||
Debe llamarse dentro del render callback de `fn::run_app`.
|
||||
@@ -0,0 +1,8 @@
|
||||
set(CMAKE_SYSTEM_NAME Linux)
|
||||
set(CMAKE_SYSTEM_PROCESSOR x86_64)
|
||||
|
||||
set(CMAKE_C_COMPILER gcc)
|
||||
set(CMAKE_CXX_COMPILER g++)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -0,0 +1,17 @@
|
||||
set(CMAKE_SYSTEM_NAME Windows)
|
||||
set(CMAKE_SYSTEM_PROCESSOR x86_64)
|
||||
|
||||
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
|
||||
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
|
||||
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
|
||||
|
||||
set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# Static link runtime so .exe is self-contained
|
||||
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -static-libgcc -static-libstdc++")
|
||||
+1
Submodule cpp/vendor/glfw added at b00e6a8a88
+1
Submodule cpp/vendor/imgui added at f5f6ca07be
+1
Submodule cpp/vendor/implot added at 524f9fcd48
+1
Submodule cpp/vendor/tracy added at 00a069d608
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -1,11 +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%))',
|
||||
'#3b82f6',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#8b5cf6',
|
||||
'#ef4444',
|
||||
]
|
||||
|
||||
export function getChartColor(index: number): string {
|
||||
return chartColors[index % chartColors.length]
|
||||
return chartColors[index % chartColors.length]!
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: cn
|
||||
kind: function
|
||||
lang: ts
|
||||
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]
|
||||
params:
|
||||
- name: inputs
|
||||
desc: "Clases CSS en cualquier formato: strings, arrays, objetos con condiciones booleanas"
|
||||
output: "String con clases CSS combinadas y mergeadas, sin duplicados y conflictos Tailwind resueltos"
|
||||
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.
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
name: generate_theme_css
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "generateThemeCss(colors: Record<string, string>, selector?: string): string"
|
||||
description: "Genera un bloque CSS con variables de tema a partir de un objeto de tokens. Convierte claves camelCase a kebab-case automaticamente. Pura — solo transforma datos, no accede al DOM."
|
||||
tags: [theme, css, generator, pure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: colors
|
||||
desc: "Objeto con pares clave-valor de nombre variable CSS a valor de color"
|
||||
- name: selector
|
||||
desc: "Selector CSS donde inyectar variables (':root' por defecto)"
|
||||
output: "String con bloque CSS completo conteniendo definiciones de variables de tema"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/generate_theme_css.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
import { generateThemeCss } from './generate_theme_css'
|
||||
import { themeConfigToColors } from './theme_config_to_colors'
|
||||
import { darkTheme } from '../ui/themes'
|
||||
|
||||
// Generar CSS para inyectar en <style>
|
||||
const colors = themeConfigToColors(darkTheme)
|
||||
const css = generateThemeCss(colors)
|
||||
// Output:
|
||||
// :root {
|
||||
// --background: oklch(8% 0.015 260);
|
||||
// --foreground: oklch(95% 0.01 260);
|
||||
// --card: oklch(12% 0.015 260);
|
||||
// --card-foreground: oklch(95% 0.01 260);
|
||||
// ...
|
||||
// }
|
||||
|
||||
// Inyectar en el documento
|
||||
const style = document.createElement('style')
|
||||
style.textContent = css
|
||||
document.head.appendChild(style)
|
||||
|
||||
// Generar para selector especifico (dark mode)
|
||||
const darkCss = generateThemeCss(colors, '.dark')
|
||||
// Output: .dark { --background: oklch(...); ... }
|
||||
|
||||
// Generar para multiples selectores
|
||||
const lightCss = generateThemeCss(lightColors, ':root')
|
||||
const darkCss2 = generateThemeCss(darkColors, ':root.dark')
|
||||
const combined = [lightCss, darkCss2].join('\n\n')
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — sin acceso al DOM, sin side effects. Util para SSR, generacion de archivos CSS estaticos, o pre-generar temas en build time.
|
||||
|
||||
La conversion camelCase → kebab-case es simple (reemplaza mayusculas con `-` + minuscula). No maneja casos especiales como `backgroundColor` → `background-color`; los tokens del registry ya usan nombres semanticos directos (`background`, `cardForeground`, etc.).
|
||||
|
||||
Compone naturalmente con `themeConfigToColors` del registry: `generateThemeCss(themeConfigToColors(config))`.
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Genera un bloque CSS con variables de tema para inyectar como <style> o
|
||||
* escribir en un archivo .css.
|
||||
*
|
||||
* Convierte claves camelCase a kebab-case automaticamente:
|
||||
* `cardForeground` → `--card-foreground`
|
||||
*
|
||||
* @param colors - Objeto con tokens de tema. Claves en camelCase, valores CSS.
|
||||
* @param selector - Selector CSS donde aplicar las variables. Por defecto `:root`.
|
||||
* @returns String CSS con el bloque completo.
|
||||
*/
|
||||
export function generateThemeCss(
|
||||
colors: Record<string, string>,
|
||||
selector: string = ':root',
|
||||
): string {
|
||||
const lines = Object.entries(colors)
|
||||
.map(([key, value]) => {
|
||||
const cssName = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
|
||||
return ` --${cssName}: ${value};`
|
||||
})
|
||||
.join('\n')
|
||||
return `${selector} {\n${lines}\n}`
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']
|
||||
|
||||
export function getSeriesColor(index: number, color?: string): string {
|
||||
return color || defaultColors[index % defaultColors.length]
|
||||
return color ?? defaultColors[index % defaultColors.length]!
|
||||
}
|
||||
|
||||
export { defaultColors }
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
name: get_theme_tokens
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "getThemeTokens(): ThemeTokens"
|
||||
description: "Lee todas las CSS variables de tema del documento y devuelve un objeto tipado con los valores computados desde :root. Util para pasar colores a APIs que no entienden CSS variables (canvas, sigma.js, D3)."
|
||||
tags: [theme, css, tokens, runtime, dom]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
output: "Objeto ThemeTokens con todas las variables CSS de tema resueltas (colores, tipografía, espaciado)"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/get_theme_tokens.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
import { getThemeTokens } from './get_theme_tokens'
|
||||
|
||||
const tokens = getThemeTokens()
|
||||
|
||||
// Pasar colores a sigma.js (que no soporta CSS variables)
|
||||
const sigmaSettings = {
|
||||
defaultNodeColor: tokens.primary,
|
||||
defaultEdgeColor: tokens.muted,
|
||||
labelColor: { color: tokens.foreground },
|
||||
}
|
||||
|
||||
// Pasar colores a un canvas 2D
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.fillStyle = tokens.background
|
||||
ctx.strokeStyle = tokens.border
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — accede a `document.documentElement` y `getComputedStyle`. Solo disponible en browser.
|
||||
|
||||
Los valores retornados son los valores sin procesar de las CSS variables (ej: `oklch(8% 0.015 260)`). Para obtener valores RGB computed (necesarios para algunas APIs), usar `getComputedColor`.
|
||||
|
||||
Funciona con cualquier tema activo: el resultado cambia automaticamente cuando se cambia el tema via `applyTheme`.
|
||||
@@ -1,59 +0,0 @@
|
||||
/** Tokens de tema leidos de las CSS variables activas en :root. */
|
||||
export interface ThemeTokens {
|
||||
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
|
||||
border: string
|
||||
input: string
|
||||
ring: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee todas las CSS variables de tema del documento y devuelve un objeto
|
||||
* tipado con los valores computados desde :root.
|
||||
*
|
||||
* Util para pasar colores a APIs que no entienden CSS variables
|
||||
* (canvas, sigma.js, D3, etc.).
|
||||
*/
|
||||
export function getThemeTokens(): ThemeTokens {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const get = (name: string) => style.getPropertyValue(`--${name}`).trim()
|
||||
return {
|
||||
background: get('background'),
|
||||
foreground: get('foreground'),
|
||||
card: get('card'),
|
||||
cardForeground: get('card-foreground'),
|
||||
popover: get('popover'),
|
||||
popoverForeground: get('popover-foreground'),
|
||||
primary: get('primary'),
|
||||
primaryForeground: get('primary-foreground'),
|
||||
secondary: get('secondary'),
|
||||
secondaryForeground: get('secondary-foreground'),
|
||||
muted: get('muted'),
|
||||
mutedForeground: get('muted-foreground'),
|
||||
accent: get('accent'),
|
||||
accentForeground: get('accent-foreground'),
|
||||
destructive: get('destructive'),
|
||||
destructiveForeground: get('destructive-foreground'),
|
||||
success: get('success'),
|
||||
successForeground: get('success-foreground'),
|
||||
border: get('border'),
|
||||
input: get('input'),
|
||||
ring: get('ring'),
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
name: theme_config_to_colors
|
||||
kind: function
|
||||
lang: ts
|
||||
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: []
|
||||
params:
|
||||
- name: config
|
||||
desc: "Configuración de tema con propiedades semánticas de color"
|
||||
output: "Objeto ThemeColors con variables CSS estandarizadas mapeadas de la config"
|
||||
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).
|
||||
@@ -1,49 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,14 @@ domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Accordion(props: AccordionProps): JSX.Element"
|
||||
description: "Secciones colapsables con animaciones. Base-UI Collapsible primitive. Composable: AccordionItem + AccordionTrigger + AccordionContent."
|
||||
tags: [accordion, collapsible, component, ui, interactive, base-ui]
|
||||
uses_functions: [cn_ts_core]
|
||||
description: "Secciones colapsables con animaciones. Mantine Accordion. Composable: AccordionItem + AccordionTrigger + AccordionContent."
|
||||
tags: [accordion, collapsible, component, ui, interactive, mantine]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react/collapsible", "lucide-react"]
|
||||
imports: ["@mantine/core"]
|
||||
output: "Componente Accordion que renderiza secciones colapsables con soporte para múltiples items abiertos simultáneamente"
|
||||
tested: false
|
||||
tests: []
|
||||
@@ -33,14 +33,14 @@ variant: []
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Accordion>
|
||||
<AccordionItem defaultOpen>
|
||||
<Accordion defaultValue="section-1">
|
||||
<AccordionItem value="section-1">
|
||||
<AccordionTrigger>Seccion 1</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Contenido de la primera seccion.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<AccordionItem value="section-2">
|
||||
<AccordionTrigger>Seccion 2</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Contenido de la segunda seccion.
|
||||
@@ -51,4 +51,4 @@ variant: []
|
||||
|
||||
## Notas
|
||||
|
||||
Cada AccordionItem es un Collapsible independiente — permite multiples items abiertos simultaneamente. Para exclusividad (solo uno abierto), manejar el estado externamente. El chevron rota 180 grados con [data-open]. Exports: Accordion, AccordionItem, AccordionTrigger, AccordionContent.
|
||||
Usa Mantine Accordion nativo. Soporta type single (default) y multiple para multiples items abiertos. El chevron se maneja automaticamente por Mantine. AccordionItem requiere prop value unico. Exports: Accordion, AccordionItem, AccordionTrigger, AccordionContent.
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { cn } from "../core/cn"
|
||||
import { Accordion as MantineAccordion } from "@mantine/core"
|
||||
|
||||
interface AccordionItem {
|
||||
value: string
|
||||
@@ -19,61 +17,72 @@ interface AccordionProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
function Accordion({ className, children, ...props }: React.ComponentProps<"div"> & AccordionProps) {
|
||||
function Accordion({ className, type, defaultValue, children }: AccordionProps) {
|
||||
if (type === "multiple") {
|
||||
return (
|
||||
<MantineAccordion
|
||||
multiple
|
||||
data-slot="accordion"
|
||||
className={className}
|
||||
defaultValue={Array.isArray(defaultValue) ? defaultValue : undefined}
|
||||
>
|
||||
{children}
|
||||
</MantineAccordion>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="accordion" className={cn("divide-y divide-border", className)} {...props}>
|
||||
<MantineAccordion
|
||||
data-slot="accordion"
|
||||
className={className}
|
||||
defaultValue={typeof defaultValue === "string" ? defaultValue : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</MantineAccordion>
|
||||
)
|
||||
}
|
||||
|
||||
interface AccordionItemProps extends CollapsiblePrimitive.Root.Props {
|
||||
interface AccordionItemProps {
|
||||
value: string
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionItemProps) {
|
||||
function AccordionItem({ className, value, children, ...props }: AccordionItemProps) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Root
|
||||
<MantineAccordion.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("group/accordion-item", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({ className, children, ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between py-4 text-sm font-medium transition-all outline-none",
|
||||
"hover:underline focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:underline",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"[&[data-open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
value={value}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</CollapsiblePrimitive.Trigger>
|
||||
</MantineAccordion.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({ className, children, ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
function AccordionTrigger({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className={cn(
|
||||
"overflow-hidden text-sm",
|
||||
"data-open:animate-in data-open:fade-in-0",
|
||||
"data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
<MantineAccordion.Control
|
||||
data-slot="accordion-trigger"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<div className="pb-4">{children}</div>
|
||||
</CollapsiblePrimitive.Panel>
|
||||
{children}
|
||||
</MantineAccordion.Control>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
||||
return (
|
||||
<MantineAccordion.Panel
|
||||
data-slot="accordion-content"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MantineAccordion.Panel>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: action_icon
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "FnActionIcon(props: FnActionIconProps): JSX.Element"
|
||||
description: "Boton de icono con variantes, loading y tooltip opcional. Wrapper sobre Mantine ActionIcon."
|
||||
tags: [mantine, button, icon, action, tooltip, component, ui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@mantine/core"]
|
||||
framework: react
|
||||
props:
|
||||
- name: icon
|
||||
type: "ReactNode"
|
||||
required: true
|
||||
description: "Icono a renderizar dentro del boton"
|
||||
- name: variant
|
||||
type: "'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'"
|
||||
required: false
|
||||
description: "Variante visual del boton, default 'default'"
|
||||
- name: size
|
||||
type: "MantineSize | number"
|
||||
required: false
|
||||
description: "Tamano del boton"
|
||||
- name: color
|
||||
type: "MantineColor"
|
||||
required: false
|
||||
description: "Color del boton"
|
||||
- name: onClick
|
||||
type: "MouseEventHandler"
|
||||
required: false
|
||||
description: "Callback al hacer click"
|
||||
- name: loading
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Muestra spinner de carga"
|
||||
- name: disabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Deshabilita el boton"
|
||||
- name: tooltip
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Si se provee, envuelve el boton en un Tooltip"
|
||||
output: "Boton de icono con tooltip opcional, estados loading/disabled y multiples variantes"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/action_icon.tsx"
|
||||
emits: []
|
||||
has_state: false
|
||||
variant: [filled, light, outline, transparent, default, subtle]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { FnActionIcon } from '@fn_library'
|
||||
import { IconSettings } from '@tabler/icons-react'
|
||||
|
||||
<FnActionIcon
|
||||
icon={<IconSettings size={18} />}
|
||||
tooltip="Configuracion"
|
||||
variant="light"
|
||||
onClick={() => openSettings()}
|
||||
/>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Wrapper sobre Mantine `ActionIcon`. Si se provee `tooltip`, envuelve automaticamente en Mantine `Tooltip`. Compatible con iconos de `@tabler/icons-react`.
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react'
|
||||
import { ActionIcon, Tooltip } from '@mantine/core'
|
||||
import type { MantineSize, MantineColor } from '@mantine/core'
|
||||
|
||||
interface FnActionIconProps {
|
||||
icon: React.ReactNode
|
||||
variant?: 'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'
|
||||
size?: MantineSize | number
|
||||
color?: MantineColor
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
function FnActionIcon({
|
||||
icon,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
color,
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
tooltip,
|
||||
}: FnActionIconProps) {
|
||||
const button = (
|
||||
<ActionIcon
|
||||
variant={variant}
|
||||
size={size}
|
||||
color={color}
|
||||
onClick={onClick}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return <Tooltip label={tooltip}>{button}</Tooltip>
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
export { FnActionIcon }
|
||||
export type { FnActionIconProps }
|
||||
@@ -6,15 +6,15 @@ 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_ts_core]
|
||||
description: "Alerta accesible con variantes default y destructive. Mantine Alert con slots para título, descripción y acción."
|
||||
tags: [alert, feedback, component, ui, notification, mantine]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react, class-variance-authority]
|
||||
output: "Componente Alert que renderiza una alerta accesible con slots para título, descripción, icono y acción"
|
||||
imports: ["@mantine/core", react]
|
||||
output: "Componente Alert que renderiza una alerta accesible via Mantine Alert con slots para título, descripción y acción"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
@@ -46,5 +46,5 @@ source_file: "frontend/src/components/ui/alert.tsx"
|
||||
## 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).
|
||||
AlertAction se posiciona absolute top-right para acciones secundarias (ej: boton cerrar).
|
||||
alertVariants se exporta como objeto vacio por compatibilidad (Mantine gestiona variantes via color prop).
|
||||
|
||||
@@ -1,34 +1,70 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../core/cn"
|
||||
import * as React from 'react'
|
||||
import { Alert as MantineAlert, Box, Text } from '@mantine/core'
|
||||
|
||||
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" },
|
||||
}
|
||||
)
|
||||
type AlertVariant = 'default' | 'destructive'
|
||||
|
||||
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
const variantColorMap: Record<AlertVariant, string | undefined> = {
|
||||
default: undefined,
|
||||
destructive: 'red',
|
||||
}
|
||||
|
||||
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 Alert({
|
||||
className,
|
||||
variant = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { variant?: AlertVariant }) {
|
||||
return (
|
||||
<MantineAlert
|
||||
data-slot="alert"
|
||||
color={variantColorMap[variant]}
|
||||
radius="md"
|
||||
variant="light"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MantineAlert>
|
||||
)
|
||||
}
|
||||
|
||||
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 AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<Text
|
||||
component="div"
|
||||
data-slot="alert-title"
|
||||
fw={500}
|
||||
size="sm"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="alert-action" className={cn("absolute top-2 right-2", className)} {...props} />
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<Text
|
||||
component="div"
|
||||
data-slot="alert-description"
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, style, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<Box
|
||||
data-slot="alert-action"
|
||||
style={{ position: 'absolute', top: 'var(--mantine-spacing-xs)', right: 'var(--mantine-spacing-xs)', ...style }}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const alertVariants = {} as const
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants }
|
||||
|
||||
@@ -8,12 +8,12 @@ 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_ts_core]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
imports: [react, "@mantine/core"]
|
||||
params:
|
||||
- name: props
|
||||
desc: "Configuración del dashboard: título, métricas con deltas, y lista de charts con span"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../core/cn'
|
||||
import { Stack, Group, Title, Text, Paper, SimpleGrid } from '@mantine/core'
|
||||
|
||||
interface MetricConfig {
|
||||
label: string
|
||||
@@ -34,67 +34,63 @@ export function analyticsPage({
|
||||
metrics,
|
||||
charts,
|
||||
actions,
|
||||
className,
|
||||
}: AnalyticsPageProps): React.ReactElement {
|
||||
const metricCols = metrics.length <= 2 ? { base: 1, md: 2 } : metrics.length <= 3 ? { base: 1, md: 3 } : { base: 1, md: 2, lg: 4 }
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
<Stack gap="lg">
|
||||
{/* 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">
|
||||
<Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
|
||||
<Stack gap={4}>
|
||||
<Title order={2}>{title}</Title>
|
||||
{subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
{dateRange}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* 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'
|
||||
)}>
|
||||
<SimpleGrid cols={metricCols} spacing="md">
|
||||
{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>
|
||||
<Paper key={i} p="md" withBorder shadow="xs">
|
||||
<Text size="sm" c="dimmed">{metric.label}</Text>
|
||||
<Group mt="xs" justify="space-between" align="flex-end" gap="md">
|
||||
<Stack gap={4}>
|
||||
<Text fz={30} fw={700} lh={1}>{metric.value}</Text>
|
||||
{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>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
c={metric.delta.value === 0 ? 'dimmed' : metric.delta.isPositive ? 'green' : 'red'}
|
||||
>
|
||||
{metric.delta.value > 0 ? '+' : ''}{metric.delta.value}%
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
{charts.map((chart) => (
|
||||
<div
|
||||
<Paper
|
||||
key={chart.id}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card p-4 text-card-foreground shadow-sm',
|
||||
chart.span === 2 && 'lg:col-span-2'
|
||||
)}
|
||||
p="md"
|
||||
withBorder
|
||||
shadow="xs"
|
||||
radius="md"
|
||||
style={chart.span === 2 ? { gridColumn: 'span 2' } : undefined}
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-medium text-muted-foreground">{chart.title}</h3>
|
||||
<Text size="sm" fw={500} c="dimmed" mb="sm">{chart.title}</Text>
|
||||
{chart.content}
|
||||
</div>
|
||||
</Paper>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: app_shell
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "FnAppShell(props: FnAppShellProps): JSX.Element"
|
||||
description: "Layout shell con header, navbar colapsable y area principal. Wrapper sobre Mantine AppShell."
|
||||
tags: [mantine, layout, shell, navigation, component, ui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@mantine/core"]
|
||||
framework: react
|
||||
props:
|
||||
- name: header
|
||||
type: "ReactNode"
|
||||
required: false
|
||||
description: "Contenido del header superior"
|
||||
- name: navbar
|
||||
type: "ReactNode"
|
||||
required: false
|
||||
description: "Contenido del sidebar de navegacion"
|
||||
- name: navbarWidth
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Ancho del navbar en px, default 250"
|
||||
- name: navbarCollapsed
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Si el navbar esta colapsado"
|
||||
- name: children
|
||||
type: "ReactNode"
|
||||
required: true
|
||||
description: "Contenido principal del area main"
|
||||
output: "Layout de aplicacion con header fijo, sidebar colapsable y area de contenido principal"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/app_shell.tsx"
|
||||
emits: []
|
||||
has_state: false
|
||||
variant: []
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { FnAppShell } from '@fn_library'
|
||||
|
||||
<FnAppShell
|
||||
header={<Group px="md">Logo</Group>}
|
||||
navbar={<NavLinks />}
|
||||
navbarCollapsed={collapsed}
|
||||
>
|
||||
<MainContent />
|
||||
</FnAppShell>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Wrapper sobre Mantine `AppShell`. El header tiene altura fija de 60px. El navbar colapsa tanto en mobile como en desktop cuando `navbarCollapsed` es true. El breakpoint de responsive es `sm`.
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react'
|
||||
import { AppShell } from '@mantine/core'
|
||||
|
||||
interface FnAppShellProps {
|
||||
header?: React.ReactNode
|
||||
navbar?: React.ReactNode
|
||||
navbarWidth?: number
|
||||
navbarCollapsed?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function FnAppShell({
|
||||
header,
|
||||
navbar,
|
||||
navbarWidth = 250,
|
||||
navbarCollapsed = false,
|
||||
children,
|
||||
}: FnAppShellProps) {
|
||||
return (
|
||||
<AppShell
|
||||
header={header ? { height: 60 } : undefined}
|
||||
navbar={navbar ? { width: navbarWidth, breakpoint: 'sm', collapsed: { mobile: navbarCollapsed, desktop: navbarCollapsed } } : undefined}
|
||||
padding="md"
|
||||
>
|
||||
{header && <AppShell.Header>{header}</AppShell.Header>}
|
||||
{navbar && <AppShell.Navbar p="md">{navbar}</AppShell.Navbar>}
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { FnAppShell }
|
||||
export type { FnAppShellProps }
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: apply_theme
|
||||
kind: function
|
||||
lang: ts
|
||||
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_ts_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: theme
|
||||
desc: "Objeto Theme con nombre, label y colores a inyectar como CSS variables"
|
||||
output: "Void - función impura que inyecta CSS variables en document.documentElement"
|
||||
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.
|
||||
@@ -1,111 +0,0 @@
|
||||
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 }
|
||||
@@ -6,15 +6,15 @@ 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_ts_core, chart_container_ts_ui, get_series_color_ts_core]
|
||||
description: "Gráfico de área @mantine/charts con gradientes automáticos, multi-series, stacking y tooltips."
|
||||
tags: [chart, area, visualization, mantine, gradient, component, ui]
|
||||
uses_functions: [chart_container_ts_ui]
|
||||
uses_types: [ChartSeries_ts_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [recharts]
|
||||
output: "Componente JSX que renderiza un gráfico de área con gradientes, multi-series y tooltips temáticos"
|
||||
imports: ["@mantine/charts", "@mantine/core"]
|
||||
output: "Componente JSX que renderiza un gráfico de área con gradientes, multi-series y tooltips"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
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 }
|
||||
import { AreaChart as MantineAreaChart } from '@mantine/charts'
|
||||
import { Paper } from '@mantine/core'
|
||||
import { type Series, getSeriesColor } from './chart_container'
|
||||
|
||||
interface AreaChartProps {
|
||||
data: Record<string, unknown>[]
|
||||
@@ -11,11 +8,10 @@ interface AreaChartProps {
|
||||
yKey?: string
|
||||
series?: Series[]
|
||||
stacked?: boolean
|
||||
gradient?: GradientConfig | boolean
|
||||
gradient?: boolean
|
||||
showGrid?: boolean
|
||||
showLegend?: boolean
|
||||
height?: number | string
|
||||
className?: string
|
||||
height?: number
|
||||
xAxisFormatter?: (value: unknown) => string
|
||||
yAxisFormatter?: (value: unknown) => string
|
||||
valueFormatter?: (value: number) => string
|
||||
@@ -23,40 +19,36 @@ interface AreaChartProps {
|
||||
|
||||
function AreaChartComponent({
|
||||
data, xKey, yKey, series, stacked = false, gradient = true, showGrid = true,
|
||||
showLegend = false, height = 300, className, xAxisFormatter, yAxisFormatter,
|
||||
showLegend = false, height = 300, 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
|
||||
const chartSeries = series
|
||||
? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
|
||||
: yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []
|
||||
|
||||
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>
|
||||
<Paper p="md">
|
||||
<MantineAreaChart
|
||||
h={height}
|
||||
data={data}
|
||||
dataKey={xKey}
|
||||
series={chartSeries}
|
||||
type={stacked ? 'stacked' : 'default'}
|
||||
curveType="monotone"
|
||||
withGradient={gradient}
|
||||
gridAxis={showGrid ? 'xy' : 'none'}
|
||||
withLegend={showLegend}
|
||||
withTooltip
|
||||
valueFormatter={valueFormatter}
|
||||
xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
|
||||
yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
|
||||
/>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Gradient is handled by Mantine's withGradient prop */
|
||||
type GradientConfig = { from: string; to: string }
|
||||
|
||||
export const AreaChart = AreaChartComponent
|
||||
export type { AreaChartProps, GradientConfig }
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: auth_form
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "AuthForm(config: AuthFormConfig): ReactElement"
|
||||
description: "Genera página de autenticación con toggle login/register, social buttons opcionales, campos extra en registro y validación. Basado en Mantine AuthenticationForm."
|
||||
tags: [auth, login, register, form, page, ui, mantine, toggle]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@mantine/core", "@mantine/hooks"]
|
||||
has_state: true
|
||||
framework: react
|
||||
emits: [onSubmit]
|
||||
params:
|
||||
- name: title
|
||||
desc: "Título principal que aparece en la cabecera del formulario (default: 'Welcome')"
|
||||
- name: socialButtons
|
||||
desc: "Lista de botones de login social, cada uno con label, icono opcional y callback onClick"
|
||||
- name: extraFields
|
||||
desc: "Campos de texto adicionales que se muestran únicamente en el modo registro (ej: nombre, empresa)"
|
||||
- name: onSubmit
|
||||
desc: "Callback invocado al enviar el formulario con type ('login'|'register'), email, password y valores de extraFields"
|
||||
- name: defaultType
|
||||
desc: "Modo inicial del formulario: 'login' (default) o 'register'"
|
||||
- name: paperProps
|
||||
desc: "Props de Mantine Paper para personalizar el contenedor (shadow, radius, p, etc.)"
|
||||
output: "Página de autenticación completa con toggle login/register, campos email/password, botones sociales opcionales y campo de términos en registro"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/auth_form.tsx"
|
||||
source_repo: ""
|
||||
source_license: ""
|
||||
source_file: ""
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
### Config mínima (solo login)
|
||||
|
||||
```tsx
|
||||
import { AuthForm } from '@fn_library/auth_form'
|
||||
|
||||
function LoginPage() {
|
||||
return (
|
||||
<AuthForm
|
||||
title="Acceder"
|
||||
onSubmit={({ type, email, password }) => {
|
||||
console.log(type, email, password)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Con social buttons y campos extra en registro
|
||||
|
||||
```tsx
|
||||
import { AuthForm } from '@fn_library/auth_form'
|
||||
import { IconBrandGoogle, IconBrandGithub } from '@tabler/icons-react'
|
||||
|
||||
function AuthPage() {
|
||||
return (
|
||||
<AuthForm
|
||||
title="fn_registry"
|
||||
defaultType="register"
|
||||
socialButtons={[
|
||||
{ label: 'Google', icon: <IconBrandGoogle size={16} />, onClick: () => signInWithGoogle() },
|
||||
{ label: 'GitHub', icon: <IconBrandGithub size={16} />, onClick: () => signInWithGitHub() },
|
||||
]}
|
||||
extraFields={[
|
||||
{ name: 'name', label: 'Nombre completo', placeholder: 'Lucas García', required: true },
|
||||
{ name: 'company', label: 'Empresa', placeholder: 'Acme Corp' },
|
||||
]}
|
||||
onSubmit={({ type, email, password, name, company }) => {
|
||||
if (type === 'register') {
|
||||
registerUser({ email, password, name, company })
|
||||
} else {
|
||||
loginUser({ email, password })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Función con estado interno (useToggle, useState de @mantine/hooks). Gestiona el toggle entre login y register sin prop externa — el padre solo recibe el valor final via onSubmit.type.
|
||||
|
||||
Los `extraFields` solo se renderizan en modo register. El campo de términos y condiciones (Checkbox) también es exclusivo del registro.
|
||||
|
||||
Los `socialButtons` se renderizan con un Divider "O continúa con email" cuando están presentes. Sin socialButtons el Divider no aparece.
|
||||
|
||||
El campo `password` usa PasswordInput de Mantine, que incluye el toggle de visibilidad integrado.
|
||||
@@ -0,0 +1,181 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Container,
|
||||
type PaperProps,
|
||||
} from '@mantine/core'
|
||||
import { useToggle, upperFirst } from '@mantine/hooks'
|
||||
|
||||
interface SocialButtonConfig {
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface ExtraFieldConfig {
|
||||
name: string
|
||||
label: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
interface AuthFormSubmitValues {
|
||||
type: 'login' | 'register'
|
||||
email: string
|
||||
password: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface AuthFormConfig {
|
||||
/** Título principal de la página */
|
||||
title?: string
|
||||
/** Botones de autenticación social opcionales */
|
||||
socialButtons?: SocialButtonConfig[]
|
||||
/** Campos adicionales que se muestran solo en el modo registro */
|
||||
extraFields?: ExtraFieldConfig[]
|
||||
/** Callback invocado al enviar el formulario */
|
||||
onSubmit?: (values: AuthFormSubmitValues) => void
|
||||
/** Modo inicial: 'login' (default) o 'register' */
|
||||
defaultType?: 'login' | 'register'
|
||||
/** Props adicionales para el Paper contenedor */
|
||||
paperProps?: PaperProps
|
||||
}
|
||||
|
||||
function AuthForm({
|
||||
title = 'Welcome',
|
||||
socialButtons = [],
|
||||
extraFields = [],
|
||||
onSubmit,
|
||||
defaultType = 'login',
|
||||
paperProps,
|
||||
}: AuthFormConfig): React.ReactElement {
|
||||
const [type, toggle] = useToggle<'login' | 'register'>([
|
||||
defaultType,
|
||||
defaultType === 'login' ? 'register' : 'login',
|
||||
])
|
||||
const [email, setEmail] = React.useState('')
|
||||
const [password, setPassword] = React.useState('')
|
||||
const [terms, setTerms] = React.useState(true)
|
||||
const [extraValues, setExtraValues] = React.useState<Record<string, string>>({})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit?.({ type, email, password, ...extraValues })
|
||||
}
|
||||
|
||||
const handleExtraChange = (name: string, value: string) => {
|
||||
setExtraValues((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} py={40}>
|
||||
<Paper radius="md" p="lg" withBorder {...paperProps}>
|
||||
<Title order={2} ta="center" mb="md">
|
||||
{title}
|
||||
</Title>
|
||||
|
||||
{socialButtons.length > 0 && (
|
||||
<>
|
||||
<Group grow mb="md" gap="xs">
|
||||
{socialButtons.map((btn) => (
|
||||
<Button
|
||||
key={btn.label}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
leftSection={btn.icon}
|
||||
onClick={btn.onClick}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
<Divider label="O continúa con email" labelPosition="center" my="lg" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
{type === 'register' &&
|
||||
extraFields.map((field) => (
|
||||
<TextInput
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
value={extraValues[field.name] ?? ''}
|
||||
onChange={(e) => handleExtraChange(field.name, e.currentTarget.value)}
|
||||
radius="md"
|
||||
/>
|
||||
))}
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label="Email"
|
||||
placeholder="tu@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
required
|
||||
label="Contraseña"
|
||||
placeholder="Tu contraseña"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
{type === 'register' && (
|
||||
<Checkbox
|
||||
label="Acepto los términos y condiciones"
|
||||
checked={terms}
|
||||
onChange={(e) => setTerms(e.currentTarget.checked)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
c="dimmed"
|
||||
size="xs"
|
||||
onClick={() => toggle()}
|
||||
>
|
||||
{type === 'register'
|
||||
? '¿Ya tienes cuenta? Inicia sesión'
|
||||
: '¿No tienes cuenta? Regístrate'}
|
||||
</Anchor>
|
||||
<Button type="submit" radius="xl">
|
||||
{upperFirst(type)}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
{type === 'register' && (
|
||||
<Text c="dimmed" size="xs" ta="center" mt="md">
|
||||
Al registrarte aceptas nuestra{' '}
|
||||
<Anchor size="xs" href="#">
|
||||
política de privacidad
|
||||
</Anchor>
|
||||
.
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export { AuthForm }
|
||||
export type { AuthFormConfig, AuthFormSubmitValues, SocialButtonConfig, ExtraFieldConfig }
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: autocomplete
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Autocomplete(props: AutocompleteProps): JSX.Element"
|
||||
description: "Input con sugerencias de autocompletado. Permite valores libres a diferencia de Select. Wrapper sobre Mantine Autocomplete."
|
||||
tags: [autocomplete, input, form, suggestions, component, ui, interactive, mantine]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["@mantine/core"]
|
||||
output: "Componente Autocomplete que renderiza input con dropdown de sugerencias filtradas"
|
||||
props:
|
||||
- name: data
|
||||
type: "string[] | { value: string; label?: string; group?: string }[]"
|
||||
required: true
|
||||
description: "Lista de opciones a mostrar en el dropdown"
|
||||
- name: value
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Valor controlado del input"
|
||||
- name: onChange
|
||||
type: "(value: string) => void"
|
||||
required: false
|
||||
description: "Callback al cambiar el valor del input"
|
||||
- name: label
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Etiqueta visible encima del input"
|
||||
- name: placeholder
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Texto placeholder cuando el input está vacío"
|
||||
- name: clearable
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Muestra botón para limpiar el valor"
|
||||
- name: loading
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Muestra spinner de carga en el input"
|
||||
- name: disabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Deshabilita el input"
|
||||
- name: limit
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Número máximo de sugerencias a mostrar en el dropdown"
|
||||
- name: size
|
||||
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
|
||||
required: false
|
||||
description: "Tamaño visual del input"
|
||||
emits: [onChange]
|
||||
has_state: true
|
||||
framework: react
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/autocomplete.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { Autocomplete } from '@fn_library/autocomplete'
|
||||
|
||||
// Básico — lista de strings
|
||||
function BasicAutocomplete() {
|
||||
return (
|
||||
<Autocomplete
|
||||
label="País"
|
||||
placeholder="Escribe para buscar..."
|
||||
data={['Argentina', 'Brasil', 'Chile', 'Colombia', 'Uruguay']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Con grupos
|
||||
function GroupedAutocomplete() {
|
||||
return (
|
||||
<Autocomplete
|
||||
label="Ciudad"
|
||||
placeholder="Selecciona una ciudad"
|
||||
data={[
|
||||
{ value: 'Buenos Aires', group: 'Argentina' },
|
||||
{ value: 'Rosario', group: 'Argentina' },
|
||||
{ value: 'São Paulo', group: 'Brasil' },
|
||||
{ value: 'Río de Janeiro', group: 'Brasil' },
|
||||
]}
|
||||
limit={5}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Con loading y clearable (búsqueda asíncrona)
|
||||
function AsyncAutocomplete() {
|
||||
const [value, setValue] = useState('')
|
||||
const [data, setData] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleChange = async (val: string) => {
|
||||
setValue(val)
|
||||
if (val.length < 2) return
|
||||
setLoading(true)
|
||||
const results = await fetchSuggestions(val)
|
||||
setData(results)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
label="Búsqueda"
|
||||
placeholder="Escribe al menos 2 caracteres..."
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
data={data}
|
||||
loading={loading}
|
||||
clearable
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
A diferencia de `Select`, `Autocomplete` permite que el usuario ingrese cualquier valor libre, no solo los de la lista. Ideal para búsquedas con sugerencias donde el valor final puede no estar en el dataset.
|
||||
|
||||
Cuando `data` contiene objetos con `group`, el dropdown agrupa visualmente las opciones bajo el encabezado del grupo.
|
||||
|
||||
El prop `limit` controla cuántas sugerencias se muestran simultáneamente (por defecto Mantine muestra todas). Útil para datasets grandes o búsquedas asíncronas donde se quiere limitar el ruido visual.
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Autocomplete as MantineAutocomplete, type AutocompleteProps as MantineAutcompleteProps } from '@mantine/core'
|
||||
|
||||
interface AutocompleteProps extends MantineAutcompleteProps {}
|
||||
|
||||
function Autocomplete(props: AutocompleteProps) {
|
||||
return <MantineAutocomplete {...props} />
|
||||
}
|
||||
|
||||
export { Autocomplete }
|
||||
export type { AutocompleteProps }
|
||||
@@ -6,14 +6,14 @@ domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Avatar(props: AvatarProps): JSX.Element"
|
||||
description: "Imagen de usuario circular con fallback a iniciales generadas automaticamente. 5 tamaños via CVA."
|
||||
tags: [avatar, user, image, component, ui, cva]
|
||||
uses_functions: [cn_ts_core]
|
||||
description: "Imagen de usuario circular con fallback a iniciales generadas automaticamente. 5 tamaños via Mantine Avatar."
|
||||
tags: [avatar, user, image, component, ui, mantine]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["class-variance-authority"]
|
||||
imports: ["@mantine/core"]
|
||||
output: "Componente Avatar que renderiza imagen de usuario circular con fallback a iniciales generadas"
|
||||
tested: false
|
||||
tests: []
|
||||
@@ -45,7 +45,7 @@ props:
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: []
|
||||
has_state: true
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [xs, sm, md, lg, xl]
|
||||
---
|
||||
@@ -68,4 +68,4 @@ variant: [xs, sm, md, lg, xl]
|
||||
|
||||
## Notas
|
||||
|
||||
Usa estado interno para manejar errores de carga de imagen (onError). La funcion getInitials extrae 2 iniciales del nombre completo (primera y ultima palabra). Si solo hay una palabra, toma los 2 primeros caracteres. Usa forwardRef para compatibilidad con wrappers.
|
||||
Usa Mantine Avatar que maneja errores de carga de imagen nativamente. La funcion getInitials extrae 2 iniciales del nombre completo (primera y ultima palabra). Si solo hay una palabra, toma los 2 primeros caracteres. Usa forwardRef para compatibilidad con wrappers.
|
||||
|
||||
@@ -1,69 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../core/cn"
|
||||
import * as React from 'react'
|
||||
import { Avatar as MantineAvatar } from '@mantine/core'
|
||||
|
||||
const avatarVariants = cva(
|
||||
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground select-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "size-6 text-xs",
|
||||
sm: "size-8 text-sm",
|
||||
md: "size-10 text-base",
|
||||
lg: "size-12 text-lg",
|
||||
xl: "size-16 text-xl",
|
||||
},
|
||||
},
|
||||
defaultVariants: { size: "md" },
|
||||
}
|
||||
)
|
||||
type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
interface AvatarProps
|
||||
extends React.ComponentPropsWithoutRef<"span">,
|
||||
VariantProps<typeof avatarVariants> {
|
||||
const sizeMap: Record<AvatarSize, string> = {
|
||||
xs: 'sm',
|
||||
sm: 'sm',
|
||||
md: 'md',
|
||||
lg: 'lg',
|
||||
xl: 'xl',
|
||||
}
|
||||
|
||||
interface AvatarProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
src?: string
|
||||
alt?: string
|
||||
fallback?: string
|
||||
initials?: string
|
||||
size?: AvatarSize
|
||||
}
|
||||
|
||||
function getInitials(name?: string): string {
|
||||
if (!name) return "?"
|
||||
if (!name) return '?'
|
||||
const parts = name.trim().split(/\s+/)
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
const first = parts[0] ?? ''
|
||||
const last = parts[parts.length - 1] ?? ''
|
||||
if (parts.length === 1) return first.slice(0, 2).toUpperCase()
|
||||
return ((first[0] ?? '') + (last[0] ?? '')).toUpperCase()
|
||||
}
|
||||
|
||||
const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
|
||||
({ className, size, src, alt, fallback, initials, ...props }, ref) => {
|
||||
const [imgError, setImgError] = React.useState(false)
|
||||
const showImage = src && !imgError
|
||||
/** Kept for backwards compatibility */
|
||||
const avatarVariants = sizeMap
|
||||
|
||||
const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
|
||||
({ className, size = 'md', src, alt, fallback, initials, ...props }, ref) => {
|
||||
const displayInitials = initials ?? getInitials(fallback ?? alt)
|
||||
|
||||
return (
|
||||
<span
|
||||
<MantineAvatar
|
||||
ref={ref}
|
||||
data-slot="avatar"
|
||||
className={cn(avatarVariants({ size }), className)}
|
||||
src={src}
|
||||
alt={alt ?? ''}
|
||||
size={sizeMap[size]}
|
||||
radius="xl"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ""}
|
||||
className="aspect-square size-full object-cover"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span data-slot="avatar-fallback" aria-hidden="true">
|
||||
{displayInitials}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{displayInitials}
|
||||
</MantineAvatar>
|
||||
)
|
||||
}
|
||||
)
|
||||
Avatar.displayName = "Avatar"
|
||||
Avatar.displayName = 'Avatar'
|
||||
|
||||
export { Avatar, avatarVariants }
|
||||
export type { AvatarProps }
|
||||
export type { AvatarProps, AvatarSize }
|
||||
|
||||
@@ -6,14 +6,14 @@ 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_ts_core]
|
||||
description: "Badge con 10 variantes semánticas (default, secondary, destructive, outline, ghost, link, success, warning, error, info) y 2 tamaños. Mantine Badge."
|
||||
tags: [badge, status, component, ui, indicator, mantine]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["class-variance-authority"]
|
||||
imports: ["@mantine/core"]
|
||||
output: "Componente Badge que renderiza un indicador visual con 10 variantes semánticas de estado"
|
||||
tested: false
|
||||
tests: []
|
||||
@@ -46,4 +46,4 @@ source_file: "frontend/src/components/ui/badge.tsx"
|
||||
|
||||
## Notas
|
||||
|
||||
Versión simplificada que usa span nativo en lugar de useRender de Base-UI. Mantiene todas las variantes y la composibilidad con cn().
|
||||
Usa Mantine Badge internamente. Las 10 variantes se mapean a combinaciones de variant+color de Mantine (filled, light, outline, subtle, transparent).
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../core/cn"
|
||||
import * as React from 'react'
|
||||
import { Badge as MantineBadge } from '@mantine/core'
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
)
|
||||
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'success' | 'warning' | 'error' | 'info'
|
||||
type BadgeSize = 'default' | 'sm'
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
|
||||
const variantMap: Record<BadgeVariant, { variant: string; color?: string }> = {
|
||||
default: { variant: 'filled' },
|
||||
secondary: { variant: 'light' },
|
||||
destructive: { variant: 'light', color: 'red' },
|
||||
outline: { variant: 'outline' },
|
||||
ghost: { variant: 'subtle' },
|
||||
link: { variant: 'transparent' },
|
||||
success: { variant: 'light', color: 'green' },
|
||||
warning: { variant: 'light', color: 'yellow' },
|
||||
error: { variant: 'light', color: 'red' },
|
||||
info: { variant: 'light', color: 'blue' },
|
||||
}
|
||||
|
||||
/** Kept for backwards compatibility */
|
||||
const badgeVariants = variantMap
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: BadgeVariant
|
||||
size?: BadgeSize
|
||||
}
|
||||
|
||||
function Badge({ className, variant = 'default', size = 'default', children, ...props }: BadgeProps) {
|
||||
const mv = variantMap[variant]
|
||||
|
||||
function Badge({ className, variant = "default", size = "default", ...props }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
<MantineBadge
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant, size }), className)}
|
||||
variant={mv.variant}
|
||||
color={mv.color}
|
||||
size={size === 'sm' ? 'xs' : 'sm'}
|
||||
radius="xl"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</MantineBadge>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export type { BadgeProps, BadgeVariant, BadgeSize }
|
||||
|
||||
@@ -6,15 +6,15 @@ domain: ui
|
||||
version: "1.1.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_ts_core, chart_container_ts_ui, get_series_color_ts_core]
|
||||
description: "Gráfico de barras @mantine/charts con multi-series, orientación horizontal/vertical y tooltips."
|
||||
tags: [chart, bar, visualization, mantine, component, ui]
|
||||
uses_functions: [chart_container_ts_ui]
|
||||
uses_types: [ChartSeries_ts_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [recharts]
|
||||
output: "Componente JSX que renderiza un gráfico de barras vertical u horizontal con multi-series y tooltips temáticos"
|
||||
imports: ["@mantine/charts", "@mantine/core"]
|
||||
output: "Componente JSX que renderiza un gráfico de barras vertical u horizontal con multi-series y tooltips"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
@@ -54,4 +54,4 @@ source_file: "frontend/src/components/ui/charts/bar-chart.tsx"
|
||||
|
||||
## Notas
|
||||
|
||||
En modo `horizontal=true`: el layout de Recharts es `'vertical'`, YAxis recibe `dataKey={xKey}` con `type="category"` (categorías en eje Y), XAxis recibe `type="number"` (valores en eje X). El radius de las barras se ajusta a `[0, 4, 4, 0]` para redondear la punta derecha. Este intercambio de ejes es obligatorio — sin él las barras horizontales no se renderizan.
|
||||
En modo `horizontal=true` se pasa `orientation="vertical"` a Mantine BarChart, que internamente intercambia los ejes.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
} from 'recharts'
|
||||
import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container'
|
||||
import { BarChart as MantineBarChart } from '@mantine/charts'
|
||||
import { Paper } from '@mantine/core'
|
||||
import { type Series, getSeriesColor } from './chart_container'
|
||||
|
||||
interface BarChartProps {
|
||||
data: Record<string, unknown>[]
|
||||
@@ -11,8 +10,7 @@ interface BarChartProps {
|
||||
horizontal?: boolean
|
||||
showGrid?: boolean
|
||||
showLegend?: boolean
|
||||
height?: number | string
|
||||
className?: string
|
||||
height?: number
|
||||
xAxisFormatter?: (value: unknown) => string
|
||||
yAxisFormatter?: (value: unknown) => string
|
||||
valueFormatter?: (value: number) => string
|
||||
@@ -20,32 +18,28 @@ interface BarChartProps {
|
||||
|
||||
function BarChartComponent({
|
||||
data, xKey, yKey, series, horizontal = false, showGrid = true, showLegend = false,
|
||||
height = 300, className, xAxisFormatter, yAxisFormatter, valueFormatter = (v) => v.toLocaleString(),
|
||||
height = 300, 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) }] : []
|
||||
const chartSeries = series
|
||||
? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
|
||||
: yKey ? [{ name: yKey, label: yKey, color: 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={horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]} />)}
|
||||
</RechartsBarChart>
|
||||
</ChartContainer>
|
||||
<Paper p="md">
|
||||
<MantineBarChart
|
||||
h={height}
|
||||
data={data}
|
||||
dataKey={xKey}
|
||||
series={chartSeries}
|
||||
orientation={horizontal ? 'vertical' : 'horizontal'}
|
||||
gridAxis={showGrid ? 'xy' : 'none'}
|
||||
withLegend={showLegend}
|
||||
withTooltip
|
||||
valueFormatter={valueFormatter}
|
||||
xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
|
||||
yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
|
||||
/>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@ domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Breadcrumb(props: BreadcrumbProps): JSX.Element"
|
||||
description: "Navegacion jerarquica con separadores, elipsis para paths largos y soporte para router links via asChild."
|
||||
tags: [breadcrumb, navigation, component, ui]
|
||||
uses_functions: [cn_ts_core]
|
||||
description: "Navegacion jerarquica con separadores, elipsis para paths largos y soporte para router links via asChild. Mantine Anchor/Text."
|
||||
tags: [breadcrumb, navigation, component, ui, mantine]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["lucide-react"]
|
||||
imports: ["@mantine/core", "@tabler/icons-react"]
|
||||
output: "Componente Breadcrumb que renderiza navegación jerárquica con separadores, elipsis y soporte para router links"
|
||||
tested: false
|
||||
tests: []
|
||||
@@ -69,4 +69,4 @@ variant: []
|
||||
|
||||
## Notas
|
||||
|
||||
Exports: Breadcrumb (nav), BreadcrumbList (ol), BreadcrumbItem (li), BreadcrumbLink (a con asChild), BreadcrumbPage (span aria-current=page), BreadcrumbSeparator (ChevronRight por defecto, customizable), BreadcrumbEllipsis (MoreHorizontal). BreadcrumbLink acepta asChild para usar con Link de React Router o Next.js.
|
||||
Exports: Breadcrumb (nav), BreadcrumbList (ol via Group), BreadcrumbItem (li via Group), BreadcrumbLink (Mantine Anchor con asChild), BreadcrumbPage (Text aria-current=page), BreadcrumbSeparator (IconChevronRight por defecto, customizable), BreadcrumbEllipsis (IconDots). BreadcrumbLink acepta asChild para usar con Link de React Router o Next.js. Usa Tabler icons en vez de lucide-react.
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
import * as React from "react"
|
||||
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
import { cn } from "../core/cn"
|
||||
import { Anchor, Text, Box } from "@mantine/core"
|
||||
import { IconChevronRight, IconDots } from "@tabler/icons-react"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentPropsWithoutRef<"nav">) {
|
||||
return <nav data-slot="breadcrumb" aria-label="breadcrumb" {...props} />
|
||||
function Breadcrumb({ children, ...props }: React.ComponentPropsWithoutRef<"nav">) {
|
||||
return <nav data-slot="breadcrumb" aria-label="breadcrumb" {...props}>{children}</nav>
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentPropsWithoutRef<"ol">) {
|
||||
function BreadcrumbList({ className, children, ...props }: React.ComponentPropsWithoutRef<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn("flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
<ol data-slot="breadcrumb-list" style={{ listStyle: "none", display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8, padding: 0, margin: 0 }} className={className} {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentPropsWithoutRef<"li">) {
|
||||
function BreadcrumbItem({ className, children, ...props }: React.ComponentPropsWithoutRef<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
<li data-slot="breadcrumb-item" style={{ display: "flex", alignItems: "center", gap: 8 }} className={className} {...props}>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,31 +31,29 @@ function BreadcrumbLink({
|
||||
}: React.ComponentPropsWithoutRef<"a"> & { asChild?: boolean }) {
|
||||
if (asChild) {
|
||||
return (
|
||||
<span data-slot="breadcrumb-link" className={cn("transition-colors hover:text-foreground", className)} {...(props as React.ComponentPropsWithoutRef<"span">)}>
|
||||
<Text data-slot="breadcrumb-link" component="span" size="sm" className={className} {...(props as React.ComponentPropsWithoutRef<"span">)}>
|
||||
{children}
|
||||
</span>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
data-slot="breadcrumb-link"
|
||||
href={href}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
<Anchor data-slot="breadcrumb-link" href={href} size="sm" className={className} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
</Anchor>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
|
||||
return (
|
||||
<span
|
||||
<Text
|
||||
data-slot="breadcrumb-page"
|
||||
component="span"
|
||||
size="sm"
|
||||
fw={500}
|
||||
role="link"
|
||||
aria-current="page"
|
||||
aria-disabled="true"
|
||||
className={cn("font-medium text-foreground", className)}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -67,30 +61,34 @@ function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<
|
||||
|
||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
<Box
|
||||
data-slot="breadcrumb-separator"
|
||||
component="li"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
className={className}
|
||||
style={{ display: "flex", alignItems: "center" }}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon />}
|
||||
</li>
|
||||
{children ?? <IconChevronRight size={14} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
|
||||
return (
|
||||
<span
|
||||
<Box
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
component="span"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
style={{ display: "flex", width: 36, height: 36, alignItems: "center", justifyContent: "center" }}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
<IconDots size={16} />
|
||||
<span style={{ position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0,0,0,0)", whiteSpace: "nowrap", borderWidth: 0 }}>More</span>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@ 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_ts_core]
|
||||
description: "Botón accesible con 6 variantes (default, outline, secondary, ghost, destructive, link) y 8 tamaños. Mantine Button."
|
||||
tags: [button, component, ui, interactive, mantine]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react", "class-variance-authority"]
|
||||
imports: ["@mantine/core"]
|
||||
output: "JSX.Element: botón renderizado con los estilos y comportamientos configurados"
|
||||
tested: false
|
||||
tests: []
|
||||
@@ -51,4 +51,4 @@ source_file: "frontend/src/components/ui/button.tsx"
|
||||
|
||||
## Notas
|
||||
|
||||
Componente base del sistema. Usa Base-UI Button primitive para accesibilidad completa (keyboard, ARIA). CVA para gestión type-safe de variantes.
|
||||
Componente base del sistema. Usa Mantine Button para accesibilidad completa (keyboard, ARIA). Las variantes se mapean a Mantine: default->filled, outline->outline, secondary->light, ghost->subtle, destructive->filled(red), link->transparent.
|
||||
|
||||
@@ -1,52 +1,64 @@
|
||||
"use client"
|
||||
import * as React from 'react'
|
||||
import { Button as MantineButton } from '@mantine/core'
|
||||
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../core/cn"
|
||||
type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
|
||||
type ButtonSize = 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
)
|
||||
const variantMap: Record<ButtonVariant, { variant: string; color?: string }> = {
|
||||
default: { variant: 'filled' },
|
||||
outline: { variant: 'outline' },
|
||||
secondary: { variant: 'light' },
|
||||
ghost: { variant: 'subtle' },
|
||||
destructive: { variant: 'filled', color: 'red' },
|
||||
link: { variant: 'transparent' },
|
||||
}
|
||||
|
||||
const sizeMap: Record<ButtonSize, { size: string; style?: React.CSSProperties }> = {
|
||||
default: { size: 'sm' },
|
||||
xs: { size: 'xs' },
|
||||
sm: { size: 'xs' },
|
||||
lg: { size: 'md' },
|
||||
icon: { size: 'sm', style: { width: 32, height: 32, padding: 0 } },
|
||||
'icon-xs': { size: 'xs', style: { width: 24, height: 24, padding: 0 } },
|
||||
'icon-sm': { size: 'xs', style: { width: 28, height: 28, padding: 0 } },
|
||||
'icon-lg': { size: 'md', style: { width: 36, height: 36, padding: 0 } },
|
||||
}
|
||||
|
||||
/** Kept for backwards compatibility — maps variant names to Mantine equivalents */
|
||||
const buttonVariants = variantMap
|
||||
|
||||
interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
}: ButtonProps) {
|
||||
const mv = variantMap[variant]
|
||||
const ms = sizeMap[size]
|
||||
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
<MantineButton
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
variant={mv.variant}
|
||||
color={mv.color}
|
||||
size={ms.size}
|
||||
radius="md"
|
||||
className={className}
|
||||
style={{ ...ms.style, ...style }}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</MantineButton>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize }
|
||||
|
||||
@@ -8,7 +8,7 @@ purity: impure
|
||||
signature: "Card(props: { size?: 'default' | 'sm'; variant?: 'default' | 'borderless' | 'ghost'; className?: string; children: ReactNode }): JSX.Element"
|
||||
description: "Contenedor card con header, title, description, action, content y footer. Sistema de slots composable. Variantes default, borderless y ghost para dashboards dark."
|
||||
tags: [card, container, layout, component, ui, dashboard, dark]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
|
||||
@@ -1,89 +1,99 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../core/cn"
|
||||
import * as React from 'react'
|
||||
import { Paper, Box, Text } from '@mantine/core'
|
||||
|
||||
type CardVariant = "default" | "borderless" | "ghost"
|
||||
type CardVariant = 'default' | 'borderless' | 'ghost'
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
variant = "default",
|
||||
size = 'default',
|
||||
variant = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm"; variant?: CardVariant }) {
|
||||
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm'; variant?: CardVariant }) {
|
||||
return (
|
||||
<div
|
||||
<Paper
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground 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",
|
||||
variant === "default" && "ring-1 ring-foreground/10",
|
||||
variant === "borderless" && "ring-0 shadow-none",
|
||||
variant === "ghost" && "ring-0 shadow-none bg-transparent",
|
||||
className
|
||||
)}
|
||||
withBorder={variant === 'default'}
|
||||
shadow={variant === 'default' ? 'xs' : undefined}
|
||||
radius="md"
|
||||
p={size === 'sm' ? 'sm' : 'md'}
|
||||
bg={variant === 'ghost' ? 'transparent' : undefined}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
<Box
|
||||
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
|
||||
)}
|
||||
pb="xs"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
<Text
|
||||
component="div"
|
||||
data-slot="card-title"
|
||||
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||
fw={600}
|
||||
size="sm"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
<Text
|
||||
component="div"
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardAction({ className, style, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
<Box
|
||||
data-slot="card-action"
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
style={{ position: 'absolute', top: 0, right: 0, ...style }}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
<Box
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
<Box
|
||||
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)}
|
||||
pt="sm"
|
||||
mt="auto"
|
||||
style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,15 +6,15 @@ 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_ts_core, get_series_color_ts_core]
|
||||
description: "Thin wrapper Paper y utilidades de colores/series para los charts @mantine/charts."
|
||||
tags: [chart, container, mantine, base, visualization, component, ui]
|
||||
uses_functions: []
|
||||
uses_types: [ChartSeries_ts_ui]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [recharts, react]
|
||||
output: "Componente ChartContainer que renderiza base responsive para gráficos Recharts con tooltip y legend temáticos"
|
||||
imports: ["@mantine/core"]
|
||||
output: "Componente ChartContainer Paper wrapper y utilidades getSeriesColor/Series para charts Mantine"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
@@ -40,11 +40,13 @@ source_file: "frontend/src/components/ui/charts/chart-base.tsx"
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { ChartContainer, getSeriesColor, type Series } from './chart_container'
|
||||
|
||||
<ChartContainer height={400}>
|
||||
<RechartsLineChart data={data}>...</RechartsLineChart>
|
||||
<MantineLineChart ... />
|
||||
</ChartContainer>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Exporta: ChartContainer, ChartTooltipContent, ChartTooltip, ChartLegend, chartColors, defaultColors, getSeriesColor, Series.
|
||||
Exporta: ChartContainer, defaultColors, getSeriesColor, Series. Wrapper fino sobre Mantine Paper para layout uniforme de charts.
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
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%))',
|
||||
]
|
||||
import { Paper } from '@mantine/core'
|
||||
|
||||
export const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']
|
||||
|
||||
@@ -19,62 +9,25 @@ export interface Series {
|
||||
}
|
||||
|
||||
export function getSeriesColor(index: number, color?: string): string {
|
||||
return color || defaultColors[index % defaultColors.length]
|
||||
return color || defaultColors[index % defaultColors.length]!
|
||||
}
|
||||
|
||||
interface ChartContainerProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
height?: number | string
|
||||
}
|
||||
|
||||
export function ChartContainer({ children, className, height = 300 }: ChartContainerProps) {
|
||||
export function ChartContainer({ children, 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>
|
||||
<Paper p="md" style={{ height, minWidth: 100, minHeight: typeof height === 'number' ? Math.min(height, 100) : 100 }}>
|
||||
{children}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
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} />
|
||||
}
|
||||
/** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
|
||||
export function ChartTooltipContent() { return null }
|
||||
/** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
|
||||
export function ChartTooltip() { return null }
|
||||
/** @deprecated Mantine charts handle legends internally. Kept for index.ts compat. */
|
||||
export function ChartLegend() { return null }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user