@axzydev/axzy_ui_system 1.7.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1738 -308
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +739 -3
- package/dist/index.d.ts +739 -3
- package/dist/index.js +1629 -233
- package/dist/index.js.map +1 -1
- package/dist/layered.css +1 -1
- package/package.json +14 -7
- package/src/App.tsx +60 -2
- package/src/components/atoms/image/image.props.ts +2 -0
- package/src/components/atoms/image/image.tsx +3 -2
- package/src/components/organisms/dialog/dialog.props.ts +2 -0
- package/src/components/organisms/dialog/dialog.tsx +4 -3
- package/src/dev.css +6 -0
- package/src/index.ts +90 -1
- package/src/json-renderer/catalog/axzyCatalog.ts +362 -0
- package/src/json-renderer/catalog/catalog.props.ts +128 -0
- package/src/json-renderer/catalog/resolve.ts +225 -0
- package/src/json-renderer/index.ts +70 -0
- package/src/json-renderer/kernel/NodeBoundary.tsx +53 -0
- package/src/json-renderer/kernel/SchemaRenderer.tsx +197 -0
- package/src/json-renderer/kernel/kernel.test.tsx +341 -0
- package/src/json-renderer/kernel/partial.ts +92 -0
- package/src/json-renderer/kernel/renderNode.tsx +113 -0
- package/src/json-renderer/kernel/schemaRenderer.props.ts +42 -0
- package/src/json-renderer/kernel/types.ts +159 -0
- package/src/json-renderer/shared/errors.ts +92 -0
- package/src/json-renderer/shared/limits.ts +87 -0
- package/src/json-renderer/shared/paths.ts +66 -0
- package/src/json-renderer/shared/shared.test.ts +108 -0
- package/src/json-renderer/stream/tolerantParse.test.ts +152 -0
- package/src/json-renderer/stream/tolerantParse.ts +449 -0
- package/src/json-renderer/test/setup.ts +38 -0
- package/src/json-renderer/validation/migrate.ts +83 -0
- package/src/json-renderer/validation/validate.ts +161 -0
- package/src/json-renderer/validation/validation.test.ts +165 -0
- package/src/json-renderer/vocabulary/buildContract.ts +158 -0
- package/src/sandbox/axzyConfig.test.ts +40 -0
- package/src/sandbox/axzyConfig.ts +77 -0
- package/src/sandbox/i18n.ts +218 -0
- package/src/sandbox/navigation.ts +1 -0
- package/src/sandbox/useHashRoute.ts +39 -9
- package/src/showcases/DemoJsonDashboard.tsx +489 -0
- package/src/showcases/DemoJsonDocs.tsx +339 -0
- package/src/showcases/DemoJsonPortfolio.tsx +257 -0
- package/src/showcases/JsonScreenShowcase.tsx +341 -0
- package/src/showcases/demoApp/demoData.ts +181 -0
- package/src/showcases/demoApp/nodeTree.test.ts +39 -0
- package/src/showcases/demoApp/nodeTree.ts +35 -0
- package/src/showcases/demoApp/schemas.test.ts +54 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { leerNodo, listarNodos } from "./demoApp/nodeTree";
|
|
3
|
+
import {
|
|
4
|
+
ITBadget,
|
|
5
|
+
ITButton,
|
|
6
|
+
ITSelect,
|
|
7
|
+
ITFlex,
|
|
8
|
+
ITJSONEditor,
|
|
9
|
+
ITStack,
|
|
10
|
+
ITText,
|
|
11
|
+
formatPath,
|
|
12
|
+
parsePartialSchema,
|
|
13
|
+
useITTranslation,
|
|
14
|
+
validateSchema,
|
|
15
|
+
type JsonEngineIssue,
|
|
16
|
+
} from "../index";
|
|
17
|
+
|
|
18
|
+
/** Páginas que se pueden inspeccionar en la documentación. */
|
|
19
|
+
const PAGINAS = ["inicio", "usuarios", "pedidos", "productos", "reportes"] as const;
|
|
20
|
+
|
|
21
|
+
/** Pasos del recorrido de un esquema, para la columna de explicación. */
|
|
22
|
+
const PASOS = [
|
|
23
|
+
{ id: "carga", titulo: "1 · Cargar", detalle: "El host pide el JSON. El motor nunca hace red: recibe texto." },
|
|
24
|
+
{ id: "parseo", titulo: "2 · Parsear", detalle: "`parsePartialSchema` tolera un JSON a medias; lo cerrado ya se puede pintar." },
|
|
25
|
+
{ id: "validacion", titulo: "3 · Validar", detalle: "`validateSchema` revisa versión, `id` y `content` contra el catálogo." },
|
|
26
|
+
{ id: "datos", titulo: "4 · Resolver", detalle: "`{ i18n }` → texto, `{ $source }` → datos, `{ action }` → acción del host." },
|
|
27
|
+
{ id: "render", titulo: "5 · Pintar", detalle: "`ITSchemaRenderer` monta componentes de la lista blanca y aísla fallos." },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Documentación viva del motor: el JSON de una página real a la izquierda y su
|
|
32
|
+
* recorrido (parseo, validación, datos, render) a la derecha.
|
|
33
|
+
*
|
|
34
|
+
* El editor es el propio `ITJSONEditor` del sistema, así que lo que se documenta
|
|
35
|
+
* es lo que se usa.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* // Ruta: #demo/app/docs
|
|
39
|
+
* <DemoJsonDocs onOpenPage={(pagina) => onNavigate(pagina)} />
|
|
40
|
+
*/
|
|
41
|
+
/** Guía de arquitectura: quién manda en cada capa, con los archivos reales. */
|
|
42
|
+
const GUIA: { titulo: string; parrafos: string[]; codigo?: string }[] = [
|
|
43
|
+
{
|
|
44
|
+
titulo: "1 · Dos capas, no una",
|
|
45
|
+
parrafos: [
|
|
46
|
+
"El host (TSX, dev-only) es dueño del chrome: monta ITLayout e ITPage, traduce etiquetas, resuelve iconos, datos y acciones. Vive en src/showcases/DemoJsonDashboard.tsx.",
|
|
47
|
+
"El motor (src/json-renderer/) pinta el cuerpo de cada página desde JSON. Nunca toca red, funciones ni nodos React.",
|
|
48
|
+
"El JSON describe: public/demo/app/layout.json (shell) y public/demo/app/<página>.json (cuerpo).",
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
titulo: "2 · Dónde está ITLayout (y por qué no en el JSON)",
|
|
53
|
+
parrafos: [
|
|
54
|
+
"DemoJsonDashboard.tsx:356 monta <ITLayout topBar sidebar>; :398 monta <ITPage>; :340 monta <ITSchemaRenderer>.",
|
|
55
|
+
"ITLayout no puede ser un nodo del esquema: topBar.logo es un ReactNode, navigationItems[].action es una función y icon es un componente. El motor prohíbe las tres.",
|
|
56
|
+
"Por eso el JSON nombra (icon: \"users\", page: \"usuarios\", query: {estado: \"Activo\"}) y el host resuelve con el registro ICONOS y la navegación.",
|
|
57
|
+
],
|
|
58
|
+
codigo: '<ITLayout\n topBar={{ logo: <AxzyLogo/>, logoText: shell.brand.text }}\n sidebar={{ modules: shell.modules.map(\u2026) }}\n>',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
titulo: "3 · Añadir un módulo",
|
|
62
|
+
parrafos: [
|
|
63
|
+
"Edita public/demo/app/layout.json y añade un objeto a layout.modules. No hay que tocar TSX.",
|
|
64
|
+
"Si el módulo no tiene página propia, apunta page a una existente: la verás navegar igual.",
|
|
65
|
+
],
|
|
66
|
+
codigo: '{\n "id": "ajustes",\n "label": { "i18n": "sandbox.app.nav.ajustes" },\n "icon": "box",\n "page": "ajustes"\n}',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
titulo: "4 · Sub-items que filtran de verdad",
|
|
70
|
+
parrafos: [
|
|
71
|
+
"Cada sub-item puede llevar query. El host lo traduce a ?estado= en la URL y la tabla se refiltra.",
|
|
72
|
+
"Sin query, el sub-item solo navega. Nada de botones decorativos.",
|
|
73
|
+
],
|
|
74
|
+
codigo: '{\n "label": "Activos",\n "page": "usuarios",\n "query": { "estado": "Activo" }\n}',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
titulo: "5 · Iconos y marca",
|
|
78
|
+
parrafos: [
|
|
79
|
+
"El JSON usa un nombre simbólico (home, users, cart, box, chart, book). El registro ICONOS del host decide qué componente es.",
|
|
80
|
+
"Para añadir un icono: una línea en ICONOS. El JSON no cambia.",
|
|
81
|
+
"La marca sale de layout.brand (logo y texto) y de AxzyLogo en el host.",
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
titulo: "6 · Una pantalla que NO es JSON",
|
|
86
|
+
parrafos: [
|
|
87
|
+
"Soportado a propósito: hay pantallas que necesitan código (un editor, un mapa, un flujo con estado propio). El motor no las debe impedir.",
|
|
88
|
+
"Ruta 1: página del host. Crea el componente, regístralo en DEMO_APP_PAGES y en el router, y renderízalo en lugar de ITSchemaRenderer cuando la página sea esa.",
|
|
89
|
+
"Ruta 2: híbrido. El esquema pinta el 90% y un nodo con acción abre la parte TSX (diálogo, editor, visor). Es lo que hace el CRUD con su formulario.",
|
|
90
|
+
"Ruta 3: contenido embebido. El host expone un componente del catálogo que internamente ya es TSX (ITDataTable, ITJSONEditor). El esquema solo lo nombra.",
|
|
91
|
+
"La regla: si la pantalla necesita funciones, es del host; si solo describe, es JSON.",
|
|
92
|
+
],
|
|
93
|
+
codigo: '// DemoJsonDashboard.tsx\n{ pagina === "docs" ? <DemoJsonDocs /> : cuerpo }',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
titulo: "7 · axzy.json: lo global en un archivo",
|
|
97
|
+
parrafos: [
|
|
98
|
+
"public/axzy.json configura lo que no es pantalla: paleta, densidad, radios, sombras, apariencia y el botón de tema.",
|
|
99
|
+
"Se mezcla con los defaults y se recorta (densidad 0.5–1.5, radio 0–24, sombra 0–3); un valor inválido no rompe, se ignora.",
|
|
100
|
+
"Se aplica una vez en el ITThemeProvider del host, así que cambiarlo reconfigura todo el sistema: tablas, diálogos, sidebar.",
|
|
101
|
+
"Pruebas: src/sandbox/axzyConfig.test.ts cubre defaults, recortes, tipos raros y mezcla de paleta.",
|
|
102
|
+
],
|
|
103
|
+
codigo: '{\n "$schema": "axzy/config",\n "version": 1,\n "density": 0.98,\n "radius": 10,\n "shadow": 2,\n "appearance": "soft",\n "showFab": true,\n "theme": { "primary": "#06b6d4" }\n}',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
titulo: "8 · Cambiar una página",
|
|
107
|
+
parrafos: [
|
|
108
|
+
"Cada página es un archivo JSON: title, description y content (nodos del catálogo).",
|
|
109
|
+
"El componente tiene que estar en el catálogo (src/json-renderer/catalog/axzyCatalog.ts); si no, el validador devuelve JR010.",
|
|
110
|
+
"Edita el JSON, guarda y recarga. El motor valida en vivo y aquí mismo ves el veredicto.",
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
export const DemoJsonDocs: React.FC = () => {
|
|
116
|
+
const { t } = useITTranslation();
|
|
117
|
+
const [pagina, setPagina] = useState<(typeof PAGINAS)[number]>("usuarios");
|
|
118
|
+
const [texto, setTexto] = useState("");
|
|
119
|
+
const [aviso, setAviso] = useState<string | null>(null);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
let cancelado = false;
|
|
123
|
+
fetch(`/demo/app/${pagina}.json`)
|
|
124
|
+
.then((r) => {
|
|
125
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
126
|
+
return r.text();
|
|
127
|
+
})
|
|
128
|
+
.then((json) => {
|
|
129
|
+
if (!cancelado) setTexto(json);
|
|
130
|
+
})
|
|
131
|
+
.catch((fallo: unknown) => {
|
|
132
|
+
if (!cancelado) setAviso(fallo instanceof Error ? fallo.message : String(fallo));
|
|
133
|
+
});
|
|
134
|
+
return () => {
|
|
135
|
+
cancelado = true;
|
|
136
|
+
};
|
|
137
|
+
}, [pagina]);
|
|
138
|
+
|
|
139
|
+
/** El árbol y el veredicto se recalculan con cada tecla. */
|
|
140
|
+
const analisis = useMemo(() => {
|
|
141
|
+
if (!texto.trim()) return null;
|
|
142
|
+
const parcial = parsePartialSchema(texto);
|
|
143
|
+
const informe = validSchema(parcial.tree);
|
|
144
|
+
return { parcial, informe };
|
|
145
|
+
}, [texto]);
|
|
146
|
+
|
|
147
|
+
/** El validador trabaja con el árbol; nunca lanza. */
|
|
148
|
+
function validSchema(tree: unknown) {
|
|
149
|
+
try {
|
|
150
|
+
return validateSchema(tree);
|
|
151
|
+
} catch (fallo) {
|
|
152
|
+
return {
|
|
153
|
+
verdict: "invalid" as const,
|
|
154
|
+
errors: [{ code: "JR020", severity: "error" as const, message: String(fallo), path: "" }],
|
|
155
|
+
warnings: [] as JsonEngineIssue[],
|
|
156
|
+
pendingNodes: 0,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const [vista, setVista] = useState<"guia" | "json">("guia");
|
|
162
|
+
const [ruta, setRuta] = useState<number[]>([]);
|
|
163
|
+
const arbol = analisis?.parcial.tree;
|
|
164
|
+
const nodos = useMemo(() => listarNodos(arbol), [arbol]);
|
|
165
|
+
const visible = ruta.length > 0 ? leerNodo(arbol, ruta) : null;
|
|
166
|
+
const enNodo = ruta.length > 0;
|
|
167
|
+
|
|
168
|
+
const copiar = useCallback(async () => {
|
|
169
|
+
try {
|
|
170
|
+
await navigator.clipboard.writeText(texto);
|
|
171
|
+
setAviso(t("sandbox.app.docs.copied"));
|
|
172
|
+
window.setTimeout(() => setAviso(null), 2000);
|
|
173
|
+
} catch {
|
|
174
|
+
setAviso(t("sandbox.app.docs.copyError"));
|
|
175
|
+
}
|
|
176
|
+
}, [texto, t]);
|
|
177
|
+
|
|
178
|
+
const veredicto = analisis?.informe.verdict ?? "invalid";
|
|
179
|
+
const errores = analisis?.informe.errors ?? [];
|
|
180
|
+
const avisos = analisis?.informe.warnings ?? [];
|
|
181
|
+
|
|
182
|
+
const guia = (
|
|
183
|
+
<div className="flex flex-col gap-4">
|
|
184
|
+
{GUIA.map((seccion) => (
|
|
185
|
+
<section key={seccion.titulo} className="rounded-[8px] border border-slate-200 p-4 dark:border-slate-800">
|
|
186
|
+
<ITText as="h3" className="font-display !text-[15px] !font-bold">
|
|
187
|
+
{seccion.titulo}
|
|
188
|
+
</ITText>
|
|
189
|
+
{seccion.parrafos.map((parrafo) => (
|
|
190
|
+
<ITText key={parrafo} as="p" muted className="mt-2 !text-[12.5px] leading-relaxed">
|
|
191
|
+
{parrafo}
|
|
192
|
+
</ITText>
|
|
193
|
+
))}
|
|
194
|
+
{seccion.codigo && (
|
|
195
|
+
<pre className="mt-3 overflow-x-auto rounded-[6px] border border-slate-200 bg-slate-50 p-3 font-mono text-[11px] leading-relaxed text-slate-700 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-300">
|
|
196
|
+
<code>{seccion.codigo}</code>
|
|
197
|
+
</pre>
|
|
198
|
+
)}
|
|
199
|
+
</section>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div className="flex flex-col gap-4">
|
|
206
|
+
<ITFlex align="center" gap={2}>
|
|
207
|
+
<ITButton label="Guía de arquitectura" size="sm" variant={vista === "guia" ? "filled" : "outlined"} color={vista === "guia" ? "primary" : "gray"} onClick={() => setVista("guia")} />
|
|
208
|
+
<ITButton label="Esquema en vivo" size="sm" variant={vista === "json" ? "filled" : "outlined"} color={vista === "json" ? "primary" : "gray"} onClick={() => setVista("json")} />
|
|
209
|
+
</ITFlex>
|
|
210
|
+
{vista === "guia" ? guia : (
|
|
211
|
+
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)]">
|
|
212
|
+
{/* ── Columna izquierda: el JSON real, editable ───────────────────── */}
|
|
213
|
+
<ITStack direction="column" spacing={3}>
|
|
214
|
+
<ITFlex align="center" justify="between" wrap="wrap" gap={2}>
|
|
215
|
+
<ITFlex align="center" gap={2} wrap="wrap">
|
|
216
|
+
<ITText as="span" muted className="font-mono !text-[10px] uppercase !tracking-[0.12em]">
|
|
217
|
+
{t("sandbox.app.docs.pick")}
|
|
218
|
+
</ITText>
|
|
219
|
+
{PAGINAS.map((item) => (
|
|
220
|
+
<ITButton
|
|
221
|
+
key={item}
|
|
222
|
+
label={item}
|
|
223
|
+
size="sm"
|
|
224
|
+
variant={item === pagina ? "filled" : "outlined"}
|
|
225
|
+
color={item === pagina ? "primary" : "gray"}
|
|
226
|
+
onClick={() => setPagina(item)}
|
|
227
|
+
/>
|
|
228
|
+
))}
|
|
229
|
+
</ITFlex>
|
|
230
|
+
<ITFlex align="center" gap={2}>
|
|
231
|
+
<ITBadget
|
|
232
|
+
label={veredicto}
|
|
233
|
+
size="sm"
|
|
234
|
+
color={veredicto === "valid" ? "success" : veredicto === "partial" ? "warning" : "danger"}
|
|
235
|
+
/>
|
|
236
|
+
<ITText
|
|
237
|
+
as="button"
|
|
238
|
+
onClick={copiar}
|
|
239
|
+
className="cursor-pointer font-mono !text-[10px] uppercase !tracking-[0.12em] text-primary-700 dark:text-primary-400"
|
|
240
|
+
>
|
|
241
|
+
{t("sandbox.app.docs.copy")}
|
|
242
|
+
</ITText>
|
|
243
|
+
</ITFlex>
|
|
244
|
+
</ITFlex>
|
|
245
|
+
|
|
246
|
+
<ITFlex align="center" justify="between" wrap="wrap" gap={2}>
|
|
247
|
+
<ITSelect
|
|
248
|
+
name="nodo"
|
|
249
|
+
label={t("sandbox.app.docs.node")}
|
|
250
|
+
value={ruta.length > 0 ? JSON.stringify(ruta) : "raiz"}
|
|
251
|
+
options={[
|
|
252
|
+
{ value: "raiz", label: t("sandbox.app.docs.whole") },
|
|
253
|
+
...nodos.map((nodo) => ({ value: JSON.stringify(nodo.ruta), label: nodo.etiqueta })),
|
|
254
|
+
]}
|
|
255
|
+
onChange={(evento) => setRuta(evento.target.value === "raiz" ? [] : (JSON.parse(evento.target.value) as number[]))}
|
|
256
|
+
/>
|
|
257
|
+
<ITText as="span" muted className="font-mono !text-[10px] uppercase !tracking-[0.12em]">
|
|
258
|
+
{enNodo ? t("sandbox.app.docs.readOnly") : t("sandbox.app.docs.editable")}
|
|
259
|
+
</ITText>
|
|
260
|
+
</ITFlex>
|
|
261
|
+
|
|
262
|
+
<ITJSONEditor
|
|
263
|
+
value={enNodo ? JSON.stringify(visible, null, 2) : texto}
|
|
264
|
+
onChange={(siguiente) => {
|
|
265
|
+
if (!enNodo) setTexto(siguiente);
|
|
266
|
+
}}
|
|
267
|
+
readOnly={enNodo}
|
|
268
|
+
height={460}
|
|
269
|
+
showLineNumbers
|
|
270
|
+
/>
|
|
271
|
+
|
|
272
|
+
<ITText as="span" muted className="font-mono !text-[10px] uppercase !tracking-[0.12em]">
|
|
273
|
+
{`${errores.length} errores · ${avisos.length} avisos · ${analisis?.informe.pendingNodes ?? 0} nodos pendientes`}
|
|
274
|
+
</ITText>
|
|
275
|
+
|
|
276
|
+
{(errores.length > 0 || avisos.length > 0) && (
|
|
277
|
+
<ITStack direction="column" spacing={1}>
|
|
278
|
+
{[...errores, ...avisos].slice(0, 6).map((issue, indice) => (
|
|
279
|
+
<ITFlex key={`${issue.code}-${indice}`} align="start" gap={2}>
|
|
280
|
+
<ITBadget label={issue.code} size="sm" color={issue.severity === "error" ? "danger" : "warning"} />
|
|
281
|
+
<ITText as="span" className="!text-[11px]">
|
|
282
|
+
{`${issue.message}${issue.path ? ` — ${formatPath(String(issue.path).split(".").filter(Boolean))}` : ""}`}
|
|
283
|
+
</ITText>
|
|
284
|
+
</ITFlex>
|
|
285
|
+
))}
|
|
286
|
+
</ITStack>
|
|
287
|
+
)}
|
|
288
|
+
</ITStack>
|
|
289
|
+
|
|
290
|
+
{/* ── Columna derecha: el recorrido del esquema ───────────────────── */}
|
|
291
|
+
<ITStack direction="column" spacing={3}>
|
|
292
|
+
<ITText as="h3" className="font-display !text-[16px] !font-bold">
|
|
293
|
+
{t("sandbox.app.docs.steps")}
|
|
294
|
+
</ITText>
|
|
295
|
+
{PASOS.map((paso) => (
|
|
296
|
+
<div
|
|
297
|
+
key={paso.id}
|
|
298
|
+
className="rounded-[8px] border border-slate-200 px-3 py-2.5 dark:border-slate-800"
|
|
299
|
+
>
|
|
300
|
+
<ITText as="p" className="font-mono !text-[10px] uppercase !tracking-[0.12em] text-primary-700 dark:text-primary-400">
|
|
301
|
+
{paso.titulo}
|
|
302
|
+
</ITText>
|
|
303
|
+
<ITText as="p" className="mt-1 !text-[12px] leading-relaxed" muted>
|
|
304
|
+
{paso.detalle}
|
|
305
|
+
</ITText>
|
|
306
|
+
</div>
|
|
307
|
+
))}
|
|
308
|
+
|
|
309
|
+
<div className="rounded-[8px] border border-slate-200 px-3 py-2.5 dark:border-slate-800">
|
|
310
|
+
<ITText as="p" className="font-mono !text-[10px] uppercase !tracking-[0.12em] text-slate-500 dark:text-slate-400">
|
|
311
|
+
{t("sandbox.app.docs.rules")}
|
|
312
|
+
</ITText>
|
|
313
|
+
<ul className="mt-2 space-y-1.5">
|
|
314
|
+
{[
|
|
315
|
+
t("sandbox.app.docs.rule1"),
|
|
316
|
+
t("sandbox.app.docs.rule2"),
|
|
317
|
+
t("sandbox.app.docs.rule3"),
|
|
318
|
+
].map((regla) => (
|
|
319
|
+
<li key={regla} className="flex gap-2 text-[12px] leading-snug text-slate-600 dark:text-slate-300">
|
|
320
|
+
<span aria-hidden="true" className="mt-[6px] block h-[3px] w-[3px] shrink-0 rounded-full bg-primary-500" />
|
|
321
|
+
{regla}
|
|
322
|
+
</li>
|
|
323
|
+
))}
|
|
324
|
+
</ul>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
{aviso && (
|
|
328
|
+
<ITText as="span" className="font-mono !text-[10px] uppercase !tracking-[0.12em] text-primary-700 dark:text-primary-400">
|
|
329
|
+
{aviso}
|
|
330
|
+
</ITText>
|
|
331
|
+
)}
|
|
332
|
+
</ITStack>
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export default DemoJsonDocs;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ITAlert,
|
|
4
|
+
ITBadget,
|
|
5
|
+
ITFlex,
|
|
6
|
+
ITLoader,
|
|
7
|
+
ITSchemaRenderer,
|
|
8
|
+
ITText,
|
|
9
|
+
parsePartialSchema,
|
|
10
|
+
useITTranslation,
|
|
11
|
+
validateSchema,
|
|
12
|
+
type JsonEngineIssue,
|
|
13
|
+
type NodeSchema,
|
|
14
|
+
type SchemaValue,
|
|
15
|
+
type ScreenSchema,
|
|
16
|
+
} from "../index";
|
|
17
|
+
import { PRODUCTOS } from "./productos";
|
|
18
|
+
|
|
19
|
+
/** Ruta del esquema servido como archivo estático. */
|
|
20
|
+
const SCHEMA_URL = "/demo/portfolio.schema.json";
|
|
21
|
+
|
|
22
|
+
/** Acciones declaradas por el esquema, implementadas por el host (este archivo). */
|
|
23
|
+
const ACCIONES = new Set(["ver-proyecto", "ir-seccion", "abrir-github", "abrir-linkedin", "abrir-correo", "volver", "abrir-panel"]);
|
|
24
|
+
|
|
25
|
+
/** Destinos que decide el host, nunca el esquema. */
|
|
26
|
+
const DESTINOS = {
|
|
27
|
+
github: "https://github.com/maag070208",
|
|
28
|
+
linkedin: "https://www.linkedin.com/in/maag070208/",
|
|
29
|
+
correo: "mailto:aamaro@axzy.dev",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Props de la página de demostración. */
|
|
33
|
+
export interface DemoJsonPortfolioProps {
|
|
34
|
+
/** Vuelve al showroom de componentes. */
|
|
35
|
+
onBack: () => void;
|
|
36
|
+
/** Navega al detalle de un proyecto del portafolio real. */
|
|
37
|
+
onOpenProject: (id: string) => void;
|
|
38
|
+
/** Abre el dashboard de demostración con páginas. */
|
|
39
|
+
onOpenPanel: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Resultado de `{ i18n: key }` con parámetros opcionales. */
|
|
43
|
+
interface I18nRef {
|
|
44
|
+
i18n: string;
|
|
45
|
+
params?: Record<string, SchemaValue>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `true` cuando un valor es una clave de traducción declarada por el esquema. */
|
|
49
|
+
function isI18nRef(value: unknown): value is I18nRef {
|
|
50
|
+
return Boolean(value) && typeof value === "object" && typeof (value as I18nRef).i18n === "string";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Portafolio de demostración pintado **solo** con el motor de pantallas.
|
|
55
|
+
*
|
|
56
|
+
* El host hace tres cosas y nada más:
|
|
57
|
+
* 1. **Carga** el esquema desde `/demo/portfolio.schema.json` (un JSON real).
|
|
58
|
+
* 2. **Valida** con `validateSchema` y avisa si algo no encaja.
|
|
59
|
+
* 3. **Resuelve** dos cosas que el JSON no puede decidir: los textos (`i18n`) y
|
|
60
|
+
* las acciones por nombre. El esquema nunca navega ni abre URLs por su cuenta.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* // Ruta: #demo
|
|
64
|
+
* <DemoJsonPortfolio onBack={goToLanding} onOpenProject={goToProducto} />
|
|
65
|
+
*/
|
|
66
|
+
export const DemoJsonPortfolio: React.FC<DemoJsonPortfolioProps> = ({ onBack, onOpenProject, onOpenPanel }) => {
|
|
67
|
+
const { t } = useITTranslation();
|
|
68
|
+
const [esquema, setEsquema] = useState<ScreenSchema | null>(null);
|
|
69
|
+
const [error, setError] = useState<string | null>(null);
|
|
70
|
+
const [avisos, setAvisos] = useState<JsonEngineIssue[]>([]);
|
|
71
|
+
|
|
72
|
+
/** 1. El host carga el JSON; el motor nunca hace red. */
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
let cancelado = false;
|
|
75
|
+
fetch(SCHEMA_URL)
|
|
76
|
+
.then((respuesta) => {
|
|
77
|
+
if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
|
|
78
|
+
return respuesta.text();
|
|
79
|
+
})
|
|
80
|
+
.then((texto) => {
|
|
81
|
+
if (cancelado) return;
|
|
82
|
+
const parcial = parsePartialSchema(texto);
|
|
83
|
+
setEsquema(parcial.tree);
|
|
84
|
+
})
|
|
85
|
+
.catch((fallo: unknown) => {
|
|
86
|
+
if (cancelado) return;
|
|
87
|
+
setError(fallo instanceof Error ? fallo.message : String(fallo));
|
|
88
|
+
});
|
|
89
|
+
return () => {
|
|
90
|
+
cancelado = true;
|
|
91
|
+
};
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
/** 2. El host valida el esquema contra el catálogo y recoge los avisos. */
|
|
95
|
+
const informe = useMemo(() => (esquema ? validateSchema(esquema) : null), [esquema]);
|
|
96
|
+
|
|
97
|
+
/** 3a. El host resuelve textos: `{ i18n: "clave" }` → traducción. */
|
|
98
|
+
const resolveValue = useCallback(
|
|
99
|
+
(value: unknown): unknown => {
|
|
100
|
+
if (isI18nRef(value)) {
|
|
101
|
+
const params = value.params
|
|
102
|
+
? Object.fromEntries(
|
|
103
|
+
Object.entries(value.params).map(([clave, dato]) => [
|
|
104
|
+
clave,
|
|
105
|
+
typeof dato === "number" || typeof dato === "string" ? dato : String(dato),
|
|
106
|
+
])
|
|
107
|
+
)
|
|
108
|
+
: undefined;
|
|
109
|
+
return t(value.i18n, params);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
},
|
|
113
|
+
[t]
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
/** 3b. El host resuelve acciones por nombre. Nada de URLs en el esquema. */
|
|
117
|
+
const resolveAction = useCallback(
|
|
118
|
+
(node: NodeSchema, _prop: string, action: unknown) => {
|
|
119
|
+
const declarada = action as { action?: string; args?: Record<string, SchemaValue> } | undefined;
|
|
120
|
+
const nombre = declarada?.action;
|
|
121
|
+
if (!nombre) return undefined;
|
|
122
|
+
|
|
123
|
+
if (!ACCIONES.has(nombre)) {
|
|
124
|
+
// Acción desconocida: se avisa una vez y no se ejecuta nada.
|
|
125
|
+
setAvisos((previos) =>
|
|
126
|
+
previos.some((item) => item.code === "JR040" && item.path === `${nombre}`)
|
|
127
|
+
? previos
|
|
128
|
+
: [
|
|
129
|
+
...previos,
|
|
130
|
+
{
|
|
131
|
+
code: "JR040" as const,
|
|
132
|
+
severity: "warning" as const,
|
|
133
|
+
message: `La acción \`${nombre}\` no está registrada en el host.`,
|
|
134
|
+
path: node.id ?? nombre,
|
|
135
|
+
},
|
|
136
|
+
]
|
|
137
|
+
);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
switch (nombre) {
|
|
143
|
+
case "volver":
|
|
144
|
+
onBack();
|
|
145
|
+
return;
|
|
146
|
+
case "ver-proyecto": {
|
|
147
|
+
const id = String(declarada?.args?.id ?? "");
|
|
148
|
+
// El host comprueba que el proyecto existe: el esquema solo nombra.
|
|
149
|
+
if (PRODUCTOS.some((producto) => producto.id === id)) onOpenProject(id);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
case "ir-seccion": {
|
|
153
|
+
const id = String(declarada?.args?.id ?? "");
|
|
154
|
+
// El id lo declara el propio esquema y el kernel lo refleja en el DOM.
|
|
155
|
+
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
case "abrir-github":
|
|
159
|
+
window.open(DESTINOS.github, "_blank", "noopener,noreferrer");
|
|
160
|
+
return;
|
|
161
|
+
case "abrir-linkedin":
|
|
162
|
+
window.open(DESTINOS.linkedin, "_blank", "noopener,noreferrer");
|
|
163
|
+
return;
|
|
164
|
+
case "abrir-correo":
|
|
165
|
+
window.location.href = DESTINOS.correo;
|
|
166
|
+
return;
|
|
167
|
+
case "abrir-panel":
|
|
168
|
+
onOpenPanel();
|
|
169
|
+
return;
|
|
170
|
+
default:
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
[onBack, onOpenProject, onOpenPanel]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (error) {
|
|
179
|
+
return (
|
|
180
|
+
<div className="mx-auto max-w-[1100px] px-5 py-10">
|
|
181
|
+
<ITAlert variant="error" title="No se pudo cargar el esquema">
|
|
182
|
+
{`${SCHEMA_URL}: ${error}`}
|
|
183
|
+
</ITAlert>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!esquema) {
|
|
189
|
+
return (
|
|
190
|
+
<ITFlex align="center" justify="center" gap={3} className="min-h-[60vh]">
|
|
191
|
+
<ITLoader variant="spinner" size="md" color="primary" />
|
|
192
|
+
<ITText as="span" muted>
|
|
193
|
+
Cargando el esquema…
|
|
194
|
+
</ITText>
|
|
195
|
+
</ITFlex>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div className="mx-auto w-full max-w-[1120px] px-5 py-10 sm:px-8 sm:py-12">
|
|
201
|
+
{/* Barra de desarrollo: lo que el motor ha visto del esquema. */}
|
|
202
|
+
<ITFlex align="center" justify="between" wrap="wrap" gap={2} className="mb-8">
|
|
203
|
+
<ITFlex align="center" gap={2}>
|
|
204
|
+
<ITBadget
|
|
205
|
+
label={
|
|
206
|
+
informe?.verdict === "valid"
|
|
207
|
+
? "Esquema válido"
|
|
208
|
+
: informe?.verdict === "partial"
|
|
209
|
+
? "Esquema parcial"
|
|
210
|
+
: "Esquema inválido"
|
|
211
|
+
}
|
|
212
|
+
color={informe?.verdict === "valid" ? "success" : informe?.verdict === "partial" ? "warning" : "danger"}
|
|
213
|
+
size="sm"
|
|
214
|
+
/>
|
|
215
|
+
<ITText as="span" muted className="hidden font-mono !text-[10px] uppercase !tracking-[0.12em] sm:inline">
|
|
216
|
+
{`${SCHEMA_URL} · ${t("sandbox.demo.brand")}`}
|
|
217
|
+
</ITText>
|
|
218
|
+
</ITFlex>
|
|
219
|
+
<ITText as="span" muted className="hidden font-mono !text-[10px] uppercase !tracking-[0.12em] sm:inline">
|
|
220
|
+
{`${informe?.warnings.length ?? 0} avisos · ${avisos.length} de acciones`}
|
|
221
|
+
</ITText>
|
|
222
|
+
</ITFlex>
|
|
223
|
+
|
|
224
|
+
{(informe?.errors.length || informe?.warnings.length || avisos.length) ? (
|
|
225
|
+
<div className="mb-6 space-y-1.5">
|
|
226
|
+
{[...(informe?.errors ?? []), ...(informe?.warnings ?? []), ...avisos].slice(0, 6).map((aviso, indice) => (
|
|
227
|
+
<ITFlex
|
|
228
|
+
key={`${aviso.code}-${aviso.path}-${indice}`}
|
|
229
|
+
align="start"
|
|
230
|
+
gap={2}
|
|
231
|
+
className="rounded-[6px] border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
|
232
|
+
>
|
|
233
|
+
<ITBadget
|
|
234
|
+
label={aviso.code}
|
|
235
|
+
color={aviso.severity === "error" ? "danger" : "warning"}
|
|
236
|
+
size="sm"
|
|
237
|
+
/>
|
|
238
|
+
<div className="min-w-0">
|
|
239
|
+
<p className="text-[11px] leading-snug text-slate-700 dark:text-slate-300">{aviso.message}</p>
|
|
240
|
+
<p className="font-mono text-[10px] text-slate-500 dark:text-slate-400">{aviso.path || "raíz"}</p>
|
|
241
|
+
</div>
|
|
242
|
+
</ITFlex>
|
|
243
|
+
))}
|
|
244
|
+
</div>
|
|
245
|
+
) : null}
|
|
246
|
+
|
|
247
|
+
<ITSchemaRenderer
|
|
248
|
+
schema={esquema}
|
|
249
|
+
resolveValue={resolveValue}
|
|
250
|
+
resolveAction={resolveAction}
|
|
251
|
+
onIssue={(aviso) => setAvisos((previos) => (previos.some((i) => i.code === aviso.code && i.path === aviso.path) ? previos : [...previos, aviso]))}
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export default DemoJsonPortfolio;
|