rl-core-front 0.19.8 → 0.19.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.19.8",
3
+ "version": "0.19.9",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usuários, RBAC, auditoria, logs e listagens com filtro dinâmico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -1746,6 +1746,26 @@ export interface components {
1746
1746
  /** Format: date-time */
1747
1747
  finishedAt: string | null;
1748
1748
  };
1749
+ QueueStateCountsResponse: {
1750
+ /** @example 3 */
1751
+ waiting: number;
1752
+ /** @example 1 */
1753
+ active: number;
1754
+ /** @example 128 */
1755
+ completed: number;
1756
+ /** @example 2 */
1757
+ failed: number;
1758
+ /** @example 0 */
1759
+ delayed: number;
1760
+ };
1761
+ QueueCountsRowResponse: {
1762
+ /**
1763
+ * @example exports
1764
+ * @enum {string}
1765
+ */
1766
+ queue: "jobs" | "exports" | "images";
1767
+ counts: components["schemas"]["QueueStateCountsResponse"];
1768
+ };
1749
1769
  QueueCountsResponse: {
1750
1770
  /** @example 3 */
1751
1771
  waiting: number;
@@ -1757,10 +1777,16 @@ export interface components {
1757
1777
  failed: number;
1758
1778
  /** @example 0 */
1759
1779
  delayed: number;
1780
+ byQueue: components["schemas"]["QueueCountsRowResponse"][];
1760
1781
  };
1761
1782
  QueueJobResponse: {
1762
1783
  /** @example 42 */
1763
1784
  id: string;
1785
+ /**
1786
+ * @example exports
1787
+ * @enum {string}
1788
+ */
1789
+ queue: "jobs" | "exports" | "images";
1764
1790
  /** @example funkos:import */
1765
1791
  name: string;
1766
1792
  /** @example failed */
@@ -1773,15 +1799,27 @@ export interface components {
1773
1799
  * "fileName": "funkos_2026-09-04.xlsx"
1774
1800
  * }
1775
1801
  */
1776
- payload: Record<string, never> | null;
1802
+ payload: {
1803
+ [key: string]: unknown;
1804
+ } | null;
1777
1805
  /** @example 2 */
1778
1806
  attemptsMade: number;
1807
+ /** @example 3 */
1808
+ maxAttempts: number;
1779
1809
  /** @example 69 */
1780
1810
  percent: number | null;
1811
+ /** @example 5000 linha(s) */
1812
+ progressMessage: string | null;
1781
1813
  failedReason: string | null;
1814
+ stacktrace: string[] | null;
1815
+ result: {
1816
+ [key: string]: unknown;
1817
+ } | null;
1782
1818
  /** Format: date-time */
1783
1819
  createdAt: string;
1784
1820
  /** Format: date-time */
1821
+ startedAt: string | null;
1822
+ /** Format: date-time */
1785
1823
  finishedAt: string | null;
1786
1824
  };
1787
1825
  PaginatedQueueJobsResponse: {
@@ -3658,7 +3696,7 @@ export interface operations {
3658
3696
  QueueAdminController_counts: {
3659
3697
  parameters: {
3660
3698
  query?: {
3661
- queue?: "jobs" | "exports" | "images";
3699
+ queue?: "jobs" | "exports" | "images" | "all";
3662
3700
  };
3663
3701
  header?: never;
3664
3702
  path?: never;
@@ -3682,9 +3720,9 @@ export interface operations {
3682
3720
  state: "waiting" | "active" | "completed" | "failed" | "delayed";
3683
3721
  page: string;
3684
3722
  limit: string;
3685
- sortBy?: "name" | "attemptsMade" | "percent" | "createdAt" | "failedReason";
3723
+ sortBy?: "name" | "queue" | "attemptsMade" | "percent" | "createdAt" | "failedReason";
3686
3724
  sortDir?: "ASC" | "DESC";
3687
- queue?: "jobs" | "exports" | "images";
3725
+ queue?: "jobs" | "exports" | "images" | "all";
3688
3726
  };
3689
3727
  header?: never;
3690
3728
  path?: never;
@@ -25,6 +25,7 @@ export function formatDateTime(value: string | Date, locale: string): string {
25
25
  }).format(date);
26
26
  }
27
27
 
28
+ const SECOND_MS = 1000;
28
29
  const MINUTE_MS = 60_000;
29
30
  const HOUR_MS = 60 * MINUTE_MS;
30
31
 
@@ -83,6 +84,42 @@ export function formatRelativeTime(
83
84
  }
84
85
 
85
86
  /** Aplica máscara de telefone BR enquanto digita: (00) 00000-0000 */
87
+ /**
88
+ * Quanto durou, entre dois instantes ISO — "162ms", "4.2s", "1m 33s".
89
+ *
90
+ * A ponta final aberta é de propósito: sem ela, conta até agora, que é o que
91
+ * responde "está travado?" sobre algo ainda em andamento. Sem a ponta inicial
92
+ * não há duração — não começou.
93
+ */
94
+ export function formatDuration(
95
+ startedAt: string | null,
96
+ finishedAt: string | null,
97
+ ): string | null {
98
+ if (!startedAt) {
99
+ return null;
100
+ }
101
+ const started = new Date(startedAt).getTime();
102
+ const ended = finishedAt ? new Date(finishedAt).getTime() : Date.now();
103
+
104
+ if (Number.isNaN(started) || Number.isNaN(ended)) {
105
+ return null;
106
+ }
107
+
108
+ const ms = Math.max(0, ended - started);
109
+
110
+ if (ms < SECOND_MS) {
111
+ return `${ms}ms`;
112
+ }
113
+ if (ms < MINUTE_MS) {
114
+ return `${(ms / SECOND_MS).toFixed(1)}s`;
115
+ }
116
+
117
+ const minutes = Math.floor(ms / MINUTE_MS);
118
+ const seconds = Math.round((ms % MINUTE_MS) / SECOND_MS);
119
+
120
+ return `${minutes}m ${seconds}s`;
121
+ }
122
+
86
123
  export function maskPhoneBR(value: string): string {
87
124
  const d = value.replace(/\D/g, "").slice(0, 11);
88
125
  if (d.length <= 2) {return d.replace(/(\d{0,2})/, "($1");}
@@ -299,6 +299,23 @@ interface DataTableProps<T extends RowData> {
299
299
  * uma parcela dela.
300
300
  */
301
301
  highlightGroupKey?: string | null;
302
+ /**
303
+ * O detalhe da linha, aberto embaixo dela no clique.
304
+ *
305
+ * Para o dado que não cabe em célula e não merece uma tela: o motivo inteiro
306
+ * de uma falha, o payload de um job, o rastro de um erro. A alternativa é
307
+ * cortar com reticências e pôr o resto num `title` — que some ao mover o
308
+ * mouse, não dá para copiar e não existe no celular.
309
+ *
310
+ * Uma linha aberta por vez, de propósito: abrir a segunda fecha a primeira.
311
+ * Duas abertas deslocam a lista para os dois lados e a comparação que
312
+ * justificaria isso se faz melhor voltando à tabela.
313
+ *
314
+ * Sem esta prop nada muda — tabela sem detalhe não ganha coluna nem clique.
315
+ * Com ela, a linha inteira é o gatilho, menos o que já é clicável dentro
316
+ * dela: o menu de ações continua sendo o menu de ações.
317
+ */
318
+ renderRowDetail?: (row: T) => React.ReactNode;
302
319
  }
303
320
 
304
321
  /** Uma seção da listagem, como o cabeçalho de grupo a recebe. */
@@ -606,9 +623,12 @@ export function DataTable<T extends RowData>({
606
623
  defaultCollapsedGroups,
607
624
  highlightRowId = null,
608
625
  highlightGroupKey = null,
626
+ renderRowDetail,
609
627
  }: DataTableProps<T>): JSX.Element {
610
628
  const { t } = useI18n();
611
629
  const [colsOpen, setColsOpen] = useState(false);
630
+ /** A linha aberta, por id do TanStack. Uma por vez — ver `renderRowDetail`. */
631
+ const [openRow, setOpenRow] = useState<string | null>(null);
612
632
  /** As seções fechadas, por chave. */
613
633
  const [collapsedGroups, setCollapsedGroups] = useState<string[]>(
614
634
  () => defaultCollapsedGroups ?? [],
@@ -898,8 +918,31 @@ export function DataTable<T extends RowData>({
898
918
  ? atual.filter((fechada) => fechada !== key)
899
919
  : [...atual, key],
900
920
  );
921
+ const expandable = Boolean(renderRowDetail);
901
922
  const colSpan =
902
- (table.getVisibleLeafColumns().length || 1) + (reorderable ? 1 : 0);
923
+ (table.getVisibleLeafColumns().length || 1) +
924
+ (reorderable ? 1 : 0) +
925
+ (expandable ? 1 : 0);
926
+
927
+ const toggleRow = (rowId: string): void =>
928
+ setOpenRow((atual) => (atual === rowId ? null : rowId));
929
+
930
+ /*
931
+ O clique na linha abre o detalhe — menos quando nasceu num controle.
932
+
933
+ A linha carrega botões de verdade (o menu de ações, um link para o registro
934
+ de origem), e sem esta guarda clicar em "Remover" também abriria o detalhe:
935
+ o diálogo de confirmação subiria com a linha se expandindo atrás dele.
936
+ */
937
+ const onRowClick =
938
+ (rowId: string) =>
939
+ (event: ReactMouseEvent<HTMLTableRowElement>): void => {
940
+ const target = event.target as HTMLElement;
941
+ if (target.closest("button, a, input, select, textarea, [role='menuitem']")) {
942
+ return;
943
+ }
944
+ toggleRow(rowId);
945
+ };
903
946
 
904
947
  const startRowDrag =
905
948
  (rowId: string) =>
@@ -1184,6 +1227,11 @@ export function DataTable<T extends RowData>({
1184
1227
  <span className="sr-only">{t("table.reorder")}</span>
1185
1228
  </TableHead>
1186
1229
  )}
1230
+ {expandable && (
1231
+ <TableHead className="w-px">
1232
+ <span className="sr-only">{t("table.detail")}</span>
1233
+ </TableHead>
1234
+ )}
1187
1235
  {hg.headers.map((header) => {
1188
1236
  const canSort = header.column.getCanSort();
1189
1237
  const sorted = header.column.getIsSorted();
@@ -1293,12 +1341,20 @@ export function DataTable<T extends RowData>({
1293
1341
  const index = section.offset + indexNaSecao;
1294
1342
  const dragging = draggedRow === row.id;
1295
1343
  const shift = dragging ? 0 : rowShift(index);
1344
+ const open = expandable && openRow === row.id;
1296
1345
 
1297
1346
  return (
1347
+ <Fragment key={row.id}>
1298
1348
  <TableRow
1299
- key={row.id}
1300
1349
  data-row-id={row.id}
1350
+ aria-expanded={expandable ? open : undefined}
1351
+ onClick={expandable ? onRowClick(row.id) : undefined}
1301
1352
  className={cn(
1353
+ expandable && "cursor-pointer",
1354
+ // A linha aberta acesa e sem borda de baixo: é o
1355
+ // que a amarra ao próprio detalhe, em vez de os
1356
+ // dois parecerem dois registros.
1357
+ open && "bg-primary/[0.07] hover:bg-primary/[0.07] [&>td]:border-b-0",
1302
1358
  // A linha viaja com o dedo, destacada do resto da tabela —
1303
1359
  // é o que diz que o arrasto pegou, e qual linha pegou.
1304
1360
  dragging &&
@@ -1373,6 +1429,24 @@ export function DataTable<T extends RowData>({
1373
1429
  </button>
1374
1430
  </TableCell>
1375
1431
  )}
1432
+ {expandable && (
1433
+ <TableCell className="w-px pr-0">
1434
+ <button
1435
+ type="button"
1436
+ aria-label={t("table.detail")}
1437
+ aria-expanded={open}
1438
+ className="flex items-center text-muted-foreground hover:text-foreground"
1439
+ onClick={() => toggleRow(row.id)}
1440
+ >
1441
+ <ChevronRight
1442
+ className={cn(
1443
+ "h-4 w-4 transition-transform",
1444
+ open && "rotate-90",
1445
+ )}
1446
+ />
1447
+ </button>
1448
+ </TableCell>
1449
+ )}
1376
1450
  {row.getVisibleCells().map((cell) => (
1377
1451
  <TableCell
1378
1452
  key={cell.id}
@@ -1385,6 +1459,23 @@ export function DataTable<T extends RowData>({
1385
1459
  </TableCell>
1386
1460
  ))}
1387
1461
  </TableRow>
1462
+
1463
+ {/* O detalhe atravessa a tabela inteira: dentro de uma
1464
+ coluna ele herdaria a largura dela, e o texto que
1465
+ justifica abri-lo é justamente o que não cabe. */}
1466
+ {open && renderRowDetail && (
1467
+ <TableRow className="hover:bg-transparent">
1468
+ <TableCell
1469
+ colSpan={colSpan}
1470
+ className="bg-muted/40 p-0 align-top"
1471
+ >
1472
+ <div className="px-4 py-4">
1473
+ {renderRowDetail(row.original)}
1474
+ </div>
1475
+ </TableCell>
1476
+ </TableRow>
1477
+ )}
1478
+ </Fragment>
1388
1479
  );
1389
1480
  })}
1390
1481
  </Fragment>
@@ -1398,6 +1489,7 @@ export function DataTable<T extends RowData>({
1398
1489
  {/* A coluna da alça não tem fechamento, mas precisa da
1399
1490
  célula: sem ela a linha inteira sobe uma casa. */}
1400
1491
  {reorderable && <TableCell className="w-px" />}
1492
+ {expandable && <TableCell className="w-px" />}
1401
1493
  {grupo.headers.map((header) => (
1402
1494
  <TableCell
1403
1495
  key={header.id}
@@ -9,6 +9,20 @@ import { TRACK_CLASS, TRACK_ITEM_CLASS } from "./track.styles";
9
9
  export interface SegmentedOption {
10
10
  value: string;
11
11
  label: string;
12
+ /**
13
+ * Quantos itens a opção tem — uma pílula depois do rótulo.
14
+ *
15
+ * Para o controle que escolhe *onde olhar*: numa tela de filas, o número ao
16
+ * lado de cada fila diz qual tem falha antes de alguém clicar, e sem ele
17
+ * descobrir isso é abrir uma por uma. Não vale como enfeite — opção sem
18
+ * quantidade própria fica sem a pílula, e não com zero.
19
+ */
20
+ count?: number;
21
+ /**
22
+ * `danger` quando o número é o problema, e não só o tamanho: falha, pendência
23
+ * vencida. `neutral` é o padrão.
24
+ */
25
+ countTone?: "neutral" | "danger";
12
26
  }
13
27
 
14
28
  export interface SegmentedControlProps
@@ -69,6 +83,25 @@ export const SegmentedControl = ({
69
83
  className={cn(TRACK_ITEM_CLASS, fullWidth && "flex-1")}
70
84
  >
71
85
  {option.label}
86
+ {option.count !== undefined && (
87
+ <>
88
+ {/* O espaço é do nome acessível, não do desenho: sem ele o leitor
89
+ de tela anuncia "Imagens2" — a pílula encosta no rótulo porque
90
+ são dois nós de texto vizinhos. O afastamento visual é o
91
+ `ml-1.5`. */}
92
+ {" "}
93
+ <span
94
+ className={cn(
95
+ "ml-1.5 rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
96
+ option.countTone === "danger" && option.count > 0
97
+ ? "bg-destructive/15 text-destructive"
98
+ : "bg-muted text-muted-foreground",
99
+ )}
100
+ >
101
+ {option.count}
102
+ </span>
103
+ </>
104
+ )}
72
105
  </button>
73
106
  ))}
74
107
  </div>
@@ -0,0 +1,2 @@
1
+ export type { QueueJobDetailProps } from "./queue-job-detail";
2
+ export { QueueJobDetail } from "./queue-job-detail";
@@ -0,0 +1,224 @@
1
+ "use client";
2
+
3
+ import { Copy, RotateCcw, Trash2 } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import { formatDateTime, formatDuration } from "#core/_utils/format";
7
+ import { Button } from "#core/components/ui/button";
8
+ import { useI18n, useToast } from "#core/contexts";
9
+ import {
10
+ QueueJob,
11
+ QueueJobState,
12
+ } from "#core/features/queues/services/queues.service";
13
+
14
+ export interface QueueJobDetailProps {
15
+ job: QueueJob;
16
+ /** Quem não tem `queues:manage:any` lê o detalhe, mas não age sobre ele. */
17
+ canManage: boolean;
18
+ onRetry: (job: QueueJob) => void;
19
+ onRemove: (job: QueueJob) => void;
20
+ }
21
+
22
+ /**
23
+ * O job por inteiro, aberto embaixo da linha.
24
+ *
25
+ * Existe porque a tabela cortava o motivo da falha em duas linhas e deixava o
26
+ * texto inteiro num `title` do navegador — que some ao mover o mouse, não dá
27
+ * para copiar e não existe no celular. E o `stacktrace`, que o BullMQ grava no
28
+ * Redis, não chegava a aparecer: sem ele a tela diz *que* quebrou e nunca
29
+ * *onde*, sendo que quem opera a fila é exatamente quem vai consertar a causa.
30
+ *
31
+ * A fila não repete aqui: a linha continua visível logo acima, com o chip dela.
32
+ */
33
+ export function QueueJobDetail({
34
+ job,
35
+ canManage,
36
+ onRetry,
37
+ onRemove,
38
+ }: QueueJobDetailProps): JSX.Element {
39
+ const { t, locale } = useI18n();
40
+ const { notify } = useToast();
41
+
42
+ /** O que se cola num chamado: identidade do job, motivo e rastro. */
43
+ const copyError = async (): Promise<void> => {
44
+ const text = [
45
+ `${job.name} #${job.id} (${job.queue})`,
46
+ job.failedReason,
47
+ job.stacktrace?.join("\n\n"),
48
+ ]
49
+ .filter(Boolean)
50
+ .join("\n\n");
51
+
52
+ /*
53
+ A área de transferência não existe em toda origem: fora de HTTPS o
54
+ `navigator.clipboard` é `undefined`, e o erro engolido deixaria o botão
55
+ indistinguível de um botão quebrado. Avisar é o mínimo — o texto continua
56
+ na tela, selecionável.
57
+ */
58
+ try {
59
+ await navigator.clipboard.writeText(text);
60
+ notify(t("queues.copied"), "success");
61
+ } catch {
62
+ notify(t("queues.copyFailed"), "error");
63
+ }
64
+ };
65
+
66
+ const duration = formatDuration(job.startedAt, job.finishedAt);
67
+
68
+ return (
69
+ <div className="grid gap-4">
70
+ <dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
71
+ <Fact
72
+ label={t("queues.attempts")}
73
+ value={t("queues.attemptsOf", {
74
+ made: String(job.attemptsMade),
75
+ max: String(job.maxAttempts),
76
+ })}
77
+ />
78
+ <Fact
79
+ label={t("queues.createdAt")}
80
+ value={formatDateTime(job.createdAt, locale)}
81
+ />
82
+ <Fact
83
+ label={t("queues.finishedAt")}
84
+ value={job.finishedAt ? formatDateTime(job.finishedAt, locale) : "—"}
85
+ />
86
+ <Fact label={t("queues.duration")} value={duration ?? "—"} />
87
+ </dl>
88
+
89
+ {job.failedReason && (
90
+ <DetailField label={t("queues.reason")}>
91
+ <p className="rounded-md border border-destructive/30 bg-destructive/[0.08] px-3 py-2.5 text-sm font-medium text-destructive">
92
+ {job.failedReason}
93
+ </p>
94
+ </DetailField>
95
+ )}
96
+
97
+ {job.progressMessage && (
98
+ <DetailField label={t("queues.progress")}>
99
+ <p className="text-sm">
100
+ {job.percent === null
101
+ ? job.progressMessage
102
+ : `${job.percent}% — ${job.progressMessage}`}
103
+ </p>
104
+ </DetailField>
105
+ )}
106
+
107
+ <DetailField label={t("queues.payload")}>
108
+ <Block text={JSON.stringify(job.payload, null, 2)} />
109
+ </DetailField>
110
+
111
+ {job.result !== null && (
112
+ <DetailField label={t("queues.result")}>
113
+ <Block text={JSON.stringify(job.result, null, 2)} />
114
+ </DetailField>
115
+ )}
116
+
117
+ {/* Um rastro por tentativa, do mais recente para o mais antigo — é o que
118
+ distingue "quebrou três vezes pelo mesmo motivo" de "quebrou por
119
+ outro depois de reprocessar". */}
120
+ {job.stacktrace?.map((trace, index) => (
121
+ <DetailField
122
+ key={index}
123
+ label={
124
+ job.stacktrace && job.stacktrace.length > 1
125
+ ? t("queues.stacktraceAttempt", { n: String(index + 1) })
126
+ : t("queues.stacktrace")
127
+ }
128
+ >
129
+ <Block text={trace} tone="bad" />
130
+ </DetailField>
131
+ ))}
132
+
133
+ <div className="flex flex-wrap gap-2">
134
+ {job.failedReason && (
135
+ <Button variant="outline" size="sm" onClick={() => void copyError()}>
136
+ <Copy className="h-4 w-4" />
137
+ {t("queues.copyError")}
138
+ </Button>
139
+ )}
140
+ {canManage && (
141
+ <>
142
+ {/* Só o que falhou. O `retry()` do BullMQ tira o job do conjunto
143
+ `failed`, e pedi-lo para um job concluído ou ainda na fila
144
+ estoura no próprio Redis — o job não está lá. */}
145
+ {job.state === QueueJobState.FAILED && (
146
+ <Button variant="outline" size="sm" onClick={() => onRetry(job)}>
147
+ <RotateCcw className="h-4 w-4" />
148
+ {t("queues.retry")}
149
+ </Button>
150
+ )}
151
+ <Button
152
+ variant="outline"
153
+ size="sm"
154
+ className="text-destructive"
155
+ onClick={() => onRemove(job)}
156
+ >
157
+ <Trash2 className="h-4 w-4" />
158
+ {t("common.delete")}
159
+ </Button>
160
+ </>
161
+ )}
162
+ </div>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ const CAPTION_CLASS =
168
+ "text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground";
169
+
170
+ /** Um par rótulo/valor da grade do topo. */
171
+ const Fact = ({
172
+ label,
173
+ value,
174
+ }: {
175
+ label: string;
176
+ value: string;
177
+ }): JSX.Element => (
178
+ <div className="grid gap-0.5">
179
+ <dt className={CAPTION_CLASS}>{label}</dt>
180
+ <dd className="text-sm tabular-nums">{value}</dd>
181
+ </div>
182
+ );
183
+
184
+ /**
185
+ * Legenda em cima de um bloco qualquer.
186
+ *
187
+ * Não é o `Field` de `components/ui`: aquele veste controle de formulário, com
188
+ * `Label` e linha de erro. Este é leitura.
189
+ */
190
+ const DetailField = ({
191
+ label,
192
+ children,
193
+ }: {
194
+ label: string;
195
+ children: React.ReactNode;
196
+ }): JSX.Element => (
197
+ <div className="grid gap-1.5">
198
+ <span className={CAPTION_CLASS}>{label}</span>
199
+ {children}
200
+ </div>
201
+ );
202
+
203
+ /**
204
+ * Texto técnico como ele é: fonte mono, quebra de linha preservada e rolagem
205
+ * própria. O teto de altura existe para um rastro de vinte linhas não empurrar
206
+ * o resto da lista para fora da tela.
207
+ */
208
+ const Block = ({
209
+ text,
210
+ tone,
211
+ }: {
212
+ text: string;
213
+ tone?: "bad";
214
+ }): JSX.Element => (
215
+ <pre
216
+ className={
217
+ tone === "bad"
218
+ ? "max-h-52 overflow-auto rounded-md border border-destructive/30 bg-destructive/[0.06] px-3 py-2.5 font-mono text-[11.5px] leading-relaxed"
219
+ : "max-h-52 overflow-auto rounded-md border bg-card px-3 py-2.5 font-mono text-[11.5px] leading-relaxed"
220
+ }
221
+ >
222
+ {text}
223
+ </pre>
224
+ );