rl-core-front 0.15.6 → 0.16.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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/_services/api/schema.d.ts +267 -4
  3. package/src/_services/images/index.ts +81 -0
  4. package/src/_utils/upload-url.ts +27 -0
  5. package/src/components/index.ts +1 -0
  6. package/src/components/job-dock.tsx +118 -0
  7. package/src/components/ui/data-table.tsx +113 -2
  8. package/src/components/ui/image-upload.tsx +329 -0
  9. package/src/components/ui/index.ts +2 -0
  10. package/src/components/ui/tab-shortcuts.tsx +77 -0
  11. package/src/components/user-avatar.tsx +7 -13
  12. package/src/contexts/index.ts +2 -0
  13. package/src/contexts/jobs-context.tsx +146 -0
  14. package/src/contexts/jobs-store.ts +391 -0
  15. package/src/features/login/hooks/use-login-flow.ts +1 -1
  16. package/src/features/notifications/components/notification-item.tsx +34 -10
  17. package/src/features/notifications/hooks/use-notifications.ts +32 -1
  18. package/src/features/notifications/notifications-center.tsx +37 -15
  19. package/src/features/notifications/services/notifications.service.ts +10 -0
  20. package/src/features/profile/force-password-change.tsx +1 -1
  21. package/src/features/profile/profile-screen.tsx +4 -2
  22. package/src/features/recovery/reset-password-screen.tsx +1 -1
  23. package/src/features/shortcuts/components/shortcut-card.tsx +0 -5
  24. package/src/features/shortcuts/shortcuts-screen.tsx +14 -3
  25. package/src/hooks/use-image-upload.ts +169 -0
  26. package/src/hooks/use-request.ts +47 -5
  27. package/src/hooks/use-shortcut.ts +52 -3
  28. package/src/i18n/messages/en.ts +27 -1
  29. package/src/i18n/messages/pt.ts +27 -1
  30. package/src/index.ts +32 -0
  31. package/src/styles/core.css +31 -0
@@ -11,6 +11,8 @@ import {
11
11
  PaginationState,
12
12
  RowData,
13
13
  rowPaginationFeature,
14
+ rowSelectionFeature,
15
+ RowSelectionState,
14
16
  rowSortingFeature,
15
17
  sortFn_alphanumeric,
16
18
  sortFn_basic,
@@ -30,13 +32,14 @@ import {
30
32
  SlidersHorizontal,
31
33
  } from "lucide-react";
32
34
  import type { JSX, PointerEvent as ReactPointerEvent } from "react";
33
- import { Fragment, useMemo, useRef, useState } from "react";
35
+ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
34
36
 
35
37
  import { countConditions } from "#core/_utils/advanced-filter";
36
38
  import { activeFilterCount } from "#core/_utils/filter";
37
39
  import { pageRange } from "#core/_utils/page-range";
38
40
  import { Button } from "#core/components/ui/button";
39
41
  import { Card } from "#core/components/ui/card";
42
+ import { Checkbox } from "#core/components/ui/checkbox";
40
43
  import {
41
44
  ColumnVisibilityModal,
42
45
  ColumnVisibilityOption,
@@ -62,6 +65,7 @@ import { cn } from "#core/lib/utils";
62
65
 
63
66
  export const dataTableFeatures = tableFeatures({
64
67
  columnVisibilityFeature,
68
+ rowSelectionFeature,
65
69
  rowSortingFeature,
66
70
  rowPaginationFeature,
67
71
  sortedRowModel: createSortedRowModel(),
@@ -75,6 +79,48 @@ export const dataTableFeatures = tableFeatures({
75
79
 
76
80
  export type DataTableFeatures = typeof dataTableFeatures;
77
81
 
82
+ /**
83
+ * A coluna das caixas de seleção.
84
+ *
85
+ * Fora do componente porque ela não depende de nada dele além dos dois rótulos
86
+ * — e defini-la dentro faria uma coluna nova a cada render, que é o suficiente
87
+ * para o TanStack remontar a tabela e perder a seleção no meio do clique.
88
+ *
89
+ * A caixa do cabeçalho marca **a página**, e não a base: com paginação no
90
+ * servidor, "todas" seria uma promessa que a tabela não tem como cumprir — ela
91
+ * só tem em mãos a página atual.
92
+ */
93
+ const selectionColumn = <T extends RowData>(
94
+ selectAllLabel: string,
95
+ selectRowLabel: string,
96
+ ): ColumnDef<DataTableFeatures, T, unknown> => ({
97
+ id: "select",
98
+ meta: { shrink: true },
99
+ enableHiding: false,
100
+ enableSorting: false,
101
+ header: ({ table }) => (
102
+ <Checkbox
103
+ aria-label={selectAllLabel}
104
+ checked={
105
+ table.getIsAllPageRowsSelected()
106
+ ? true
107
+ : table.getIsSomePageRowsSelected()
108
+ ? "indeterminate"
109
+ : false
110
+ }
111
+ onCheckedChange={(marcado) => table.toggleAllPageRowsSelected(marcado === true)}
112
+ />
113
+ ),
114
+ cell: ({ row }) => (
115
+ <Checkbox
116
+ aria-label={selectRowLabel}
117
+ checked={row.getIsSelected()}
118
+ disabled={!row.getCanSelect()}
119
+ onCheckedChange={(marcado) => row.toggleSelected(marcado === true)}
120
+ />
121
+ ),
122
+ });
123
+
78
124
  interface DataTableProps<T extends RowData> {
79
125
  columns: ColumnDef<DataTableFeatures, T, unknown>[];
80
126
  data: T[];
@@ -103,6 +149,23 @@ interface DataTableProps<T extends RowData> {
103
149
  onSortingChange?: OnChangeFn<SortingState>;
104
150
  /** Habilita ordenação (padrão true). Desligue quando o backend ainda não ordena. */
105
151
  enableSorting?: boolean;
152
+ /**
153
+ * Liga a seleção de linhas — uma coluna de caixas antes das outras.
154
+ *
155
+ * A função existe para a linha que **não** pode entrar: numa fatura, a
156
+ * parcela já paga não se paga de novo. Desabilitar a caixa é melhor que
157
+ * escondê-la, porque a coluna continua alinhada e a linha continua contando
158
+ * como linha.
159
+ */
160
+ enableRowSelection?: boolean | ((row: T) => boolean);
161
+ /**
162
+ * O que está selecionado, a cada mudança.
163
+ *
164
+ * O `clear` vem junto de propósito: quem age sobre a seleção precisa desfazê-la
165
+ * depois — pagou as seis parcelas, as caixas têm de esvaziar. Guardar a função
166
+ * é mais simples que a tabela adivinhar quando a ação terminou.
167
+ */
168
+ onSelectionChange?: (rows: T[], clear: () => void) => void;
106
169
  /**
107
170
  * Filtros dinâmicos, como o `useListQuery` os devolve. Sem esta prop o botão
108
171
  * não aparece — listagem sem catálogo continua funcionando igual.
@@ -372,6 +435,8 @@ export function DataTable<T extends RowData>({
372
435
  sorting,
373
436
  onSortingChange,
374
437
  enableSorting = true,
438
+ enableRowSelection,
439
+ onSelectionChange,
375
440
  filters,
376
441
  hiddenFilterFields,
377
442
  emptyMessage,
@@ -453,15 +518,34 @@ export function DataTable<T extends RowData>({
453
518
  ? { pageIndex: 0, pageSize: Math.max(data.length, 1) }
454
519
  : internalPagination;
455
520
 
521
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
522
+
523
+ /*
524
+ A coluna das caixas entra na frente das outras, e só existe com a seleção
525
+ ligada: uma coluna a mais em toda tabela do sistema custaria 40px de largura
526
+ a telas que nunca vão selecionar nada.
527
+
528
+ `enableHiding: false` porque ela não é dado — esconder a caixa deixaria a
529
+ seleção ativa e invisível, que é o pior estado possível.
530
+ */
531
+ const colunas = useMemo(
532
+ () =>
533
+ enableRowSelection
534
+ ? [selectionColumn<T>(t("table.selectAll"), t("table.selectRow")), ...columns]
535
+ : columns,
536
+ [columns, enableRowSelection, t],
537
+ );
538
+
456
539
  const table = useTable({
457
540
  features: dataTableFeatures,
458
541
  data,
459
- columns,
542
+ columns: colunas,
460
543
  getRowId: getRowId ? (row) => getRowId(row) : undefined,
461
544
  state: {
462
545
  columnVisibility,
463
546
  sorting: manualSorting ? sorting : internalSorting,
464
547
  pagination: manualPagination ? pagination : paginacaoLocal,
548
+ rowSelection,
465
549
  },
466
550
  onColumnVisibilityChange: (updater) => {
467
551
  const next =
@@ -472,6 +556,11 @@ export function DataTable<T extends RowData>({
472
556
  .map(([k]) => k),
473
557
  );
474
558
  },
559
+ onRowSelectionChange: setRowSelection,
560
+ enableRowSelection:
561
+ typeof enableRowSelection === "function"
562
+ ? (row: { original: T }) => enableRowSelection(row.original)
563
+ : enableRowSelection,
475
564
  onSortingChange: manualSorting ? onSortingChange : setInternalSorting,
476
565
  onPaginationChange: manualPagination
477
566
  ? onPaginationChange
@@ -482,6 +571,28 @@ export function DataTable<T extends RowData>({
482
571
  ...(manualPagination ? { rowCount: rowCount ?? 0 } : {}),
483
572
  });
484
573
 
574
+ /*
575
+ A tela é avisada num efeito, e não dentro do `onRowSelectionChange`.
576
+
577
+ O handler roda **antes** de o estado entrar, então ali a tabela ainda
578
+ responde a seleção antiga — e a tela receberia sempre uma seleção atrasada
579
+ em um clique, que é o tipo de erro que só aparece quando alguém confere o
580
+ total do que selecionou.
581
+ */
582
+ const selecionadas = table.getSelectedRowModel().rows.map((row) => row.original);
583
+ const avisar = useRef(onSelectionChange);
584
+
585
+ useEffect(() => {
586
+ avisar.current = onSelectionChange;
587
+ }, [onSelectionChange]);
588
+
589
+ useEffect(() => {
590
+ avisar.current?.(selecionadas as T[], () => setRowSelection({}));
591
+ // A dependência é o **estado** da seleção, e não a lista derivada: esta é
592
+ // um array novo a cada render, e o efeito rodaria sempre.
593
+ // eslint-disable-next-line react-hooks/exhaustive-deps
594
+ }, [rowSelection]);
595
+
485
596
  /*
486
597
  Linha de fechamento: existe só se alguma coluna visível declarar `footer`.
487
598
 
@@ -0,0 +1,329 @@
1
+ "use client";
2
+
3
+ import { ImagePlus, Link2, Trash2, Upload } from "lucide-react";
4
+ import type { ChangeEvent, ClipboardEvent, DragEvent, JSX } from "react";
5
+ import { useRef, useState } from "react";
6
+
7
+ import { imagesService } from "#core/_services/images";
8
+ import { uploadUrlOf } from "#core/_utils/upload-url";
9
+ import { Button } from "#core/components/ui/button";
10
+ import { Input } from "#core/components/ui/input";
11
+ import { Spinner } from "#core/components/ui/spinner";
12
+ import { useI18n } from "#core/contexts/i18n-context";
13
+ import { useImageUpload } from "#core/hooks/use-image-upload";
14
+ import { cn } from "#core/lib/utils";
15
+
16
+ export interface ImageUploadProps {
17
+ /** Nome do perfil no servidor — é ele que decide tamanho, formato e limite. */
18
+ profile: string;
19
+ /** Caminho já enviado, ou `null`. Controlado, para casar com o formulário. */
20
+ value: string | null;
21
+ onChange: (path: string | null) => void;
22
+ /** Erro vindo do formulário; o do envio o componente mostra sozinho. */
23
+ error?: string;
24
+ disabled?: boolean;
25
+ /** Some com o campo de endereço — útil onde colar link não faz sentido. */
26
+ hideUrlField?: boolean;
27
+ className?: string;
28
+ }
29
+
30
+ /**
31
+ * Envia uma imagem e devolve o caminho dela.
32
+ *
33
+ * **Quatro formas de entregar a imagem**, e todas terminam no mesmo lugar:
34
+ * escolher o arquivo, arrastar e soltar, colar (⌘V) e colar o endereço — este
35
+ * último com o servidor baixando, convertendo e guardando como arquivo nosso.
36
+ * O que o resto do sistema vê é sempre um caminho.
37
+ *
38
+ * O componente **não decide formato nem tamanho**: ele pergunta o perfil ao
39
+ * servidor e obedece. É o que permite trocar 400×400 por 600×600 sem publicar
40
+ * interface, e o que mantém a listagem uniforme — quem cadastra não escolhe.
41
+ *
42
+ * O progresso aparece aqui, sobre a prévia, e também no painel do rodapé
43
+ * (`JobDock`), que sobrevive à navegação. Sem modal: o job dura um ou dois
44
+ * segundos, e cobrir o formulário esconderia os outros campos por causa de uma
45
+ * foto.
46
+ */
47
+ export function ImageUpload({
48
+ profile,
49
+ value,
50
+ onChange,
51
+ error,
52
+ disabled = false,
53
+ hideUrlField = false,
54
+ className,
55
+ }: ImageUploadProps): JSX.Element {
56
+ const { t } = useI18n();
57
+ const {
58
+ profile: info,
59
+ sending,
60
+ error: uploadError,
61
+ upload,
62
+ uploadFromUrl,
63
+ clearError,
64
+ } = useImageUpload(profile);
65
+
66
+ const inputRef = useRef<HTMLInputElement>(null);
67
+ const [dragging, setDragging] = useState(false);
68
+ const [url, setUrl] = useState("");
69
+
70
+ const bloqueado = disabled || sending;
71
+ const mensagem = error ?? uploadError;
72
+
73
+ const receber = async (file?: File | null): Promise<void> => {
74
+ if (!file || bloqueado) {
75
+ return;
76
+ }
77
+
78
+ const path = await upload(file);
79
+
80
+ if (path) {
81
+ onChange(path);
82
+ }
83
+ };
84
+
85
+ const buscarEndereco = async (): Promise<void> => {
86
+ const endereco = url.trim();
87
+
88
+ if (endereco === "" || bloqueado) {
89
+ return;
90
+ }
91
+
92
+ const path = await uploadFromUrl(endereco);
93
+
94
+ if (path) {
95
+ onChange(path);
96
+ setUrl("");
97
+ }
98
+ };
99
+
100
+ const remover = async (): Promise<void> => {
101
+ if (!value || bloqueado) {
102
+ return;
103
+ }
104
+
105
+ // O arquivo sai do disco junto: caminho que ninguém mais referencia é
106
+ // exatamente o que a faxina não varre (a pasta definitiva não é varrida).
107
+ await imagesService.remove(profile, value).catch(() => undefined);
108
+ onChange(null);
109
+ clearError();
110
+ };
111
+
112
+ /**
113
+ * Colar é o gesto de quem acabou de copiar a imagem num site.
114
+ *
115
+ * Duas formas chegam pelo mesmo evento: a imagem em si (`files`) e o
116
+ * endereço dela como texto. Sem tratar isto, a pessoa cola e nada acontece.
117
+ */
118
+ const colar = (event: ClipboardEvent<HTMLDivElement>): void => {
119
+ const arquivo = event.clipboardData.files?.[0];
120
+
121
+ if (arquivo) {
122
+ event.preventDefault();
123
+ void receber(arquivo);
124
+ return;
125
+ }
126
+
127
+ const texto = event.clipboardData.getData("text").trim();
128
+
129
+ if (/^https?:\/\//i.test(texto)) {
130
+ event.preventDefault();
131
+ setUrl(texto);
132
+ void uploadFromUrl(texto).then((path) => {
133
+ if (path) {
134
+ onChange(path);
135
+ }
136
+ });
137
+ }
138
+ };
139
+
140
+ /**
141
+ * `preventDefault` no `dragover` não é detalhe: sem ele o navegador abre a
142
+ * imagem soltada numa aba e leva a pessoa embora do formulário preenchido.
143
+ */
144
+ const arrastar = (event: DragEvent<HTMLDivElement>, ativo: boolean): void => {
145
+ event.preventDefault();
146
+ event.stopPropagation();
147
+ setDragging(ativo && !bloqueado);
148
+ };
149
+
150
+ const soltar = (event: DragEvent<HTMLDivElement>): void => {
151
+ arrastar(event, false);
152
+ // Vários arquivos num campo de uma imagem: vale o primeiro, e o resto é
153
+ // ignorado em silêncio — recusar tudo seria pior para quem só errou a mira.
154
+ void receber(event.dataTransfer.files?.[0]);
155
+ };
156
+
157
+ /*
158
+ * A área de envio tem a proporção da caixa do perfil, e quadrado é o padrão
159
+ * enquanto ela não chegou: uma faixa larga para uma foto quadrada mostraria a
160
+ * imagem pequena no meio de um vazio, e o quadro pularia de forma quando o
161
+ * perfil respondesse.
162
+ */
163
+ const proporcao =
164
+ info?.height && info.width ? `${info.width} / ${info.height}` : "1 / 1";
165
+
166
+ return (
167
+ <div className={cn("flex flex-col gap-2", className)}>
168
+ {/*
169
+ A área inteira é o alvo do clique, e não só o link no meio dela: um
170
+ quadro tracejado desse tamanho *parece* clicável, e mirar um texto de
171
+ uma linha dentro dele é trabalho que a interface não precisava pedir.
172
+ */}
173
+ <div
174
+ role="button"
175
+ aria-label={t("image.choose")}
176
+ // `tabIndex` também serve ao `paste`: um `div` sem foco não recebe o
177
+ // evento de colar.
178
+ tabIndex={bloqueado ? -1 : 0}
179
+ onClick={() => !bloqueado && inputRef.current?.click()}
180
+ onKeyDown={(event) => {
181
+ // Alvo clicável tem de responder ao teclado como um botão responde.
182
+ if (event.key === "Enter" || event.key === " ") {
183
+ event.preventDefault();
184
+ if (!bloqueado) {
185
+ inputRef.current?.click();
186
+ }
187
+ }
188
+ }}
189
+ onPaste={colar}
190
+ onDragOver={(event) => arrastar(event, true)}
191
+ onDragLeave={(event) => arrastar(event, false)}
192
+ onDrop={soltar}
193
+ className={cn(
194
+ "relative flex w-full items-center justify-center overflow-hidden rounded-lg border border-dashed border-border bg-muted/40 outline-none transition-colors",
195
+ "focus-visible:ring-2 focus-visible:ring-ring",
196
+ dragging && "border-primary bg-primary/5",
197
+ bloqueado ? "cursor-default" : "cursor-pointer hover:border-primary/60",
198
+ )}
199
+ style={{ aspectRatio: proporcao }}
200
+ >
201
+ {value ? (
202
+ // eslint-disable-next-line @next/next/no-img-element
203
+ <img
204
+ src={uploadUrlOf(value)}
205
+ alt=""
206
+ className="h-full w-full object-contain"
207
+ />
208
+ ) : (
209
+ <div className="flex flex-col items-center gap-2 p-4 text-center">
210
+ <ImagePlus className="h-8 w-8 text-muted-foreground/60" />
211
+ <p className="text-xs text-muted-foreground">
212
+ {t("image.empty")}{" "}
213
+ <button
214
+ type="button"
215
+ disabled={bloqueado}
216
+ // `stopPropagation` porque o container também abre o seletor:
217
+ // sem isto, o clique aqui contaria duas vezes e o diálogo do
218
+ // sistema abriria em dobro.
219
+ onClick={(event) => {
220
+ event.stopPropagation();
221
+ inputRef.current?.click();
222
+ }}
223
+ className="font-medium text-primary underline-offset-2 hover:underline disabled:opacity-60"
224
+ >
225
+ {t("image.choose")}
226
+ </button>
227
+ </p>
228
+ {info && (
229
+ <p className="text-[11px] text-muted-foreground/80">
230
+ {t("image.hint", {
231
+ format: info.format.toUpperCase(),
232
+ width: info.width,
233
+ })}
234
+ </p>
235
+ )}
236
+ </div>
237
+ )}
238
+
239
+ {/* O estado aparece onde a imagem vai ficar, e não num modal. */}
240
+ {sending && (
241
+ <div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background/70">
242
+ <Spinner className="h-5 w-5" />
243
+ <span className="text-xs text-muted-foreground">{t("image.sending")}</span>
244
+ </div>
245
+ )}
246
+
247
+ {value && !sending && (
248
+ <div className="absolute right-2 top-2 flex gap-1">
249
+ <Button
250
+ type="button"
251
+ size="icon"
252
+ variant="outline"
253
+ aria-label={t("image.replace")}
254
+ disabled={bloqueado}
255
+ onClick={(event) => {
256
+ event.stopPropagation();
257
+ inputRef.current?.click();
258
+ }}
259
+ >
260
+ <Upload className="h-4 w-4" />
261
+ </Button>
262
+ <Button
263
+ type="button"
264
+ size="icon"
265
+ variant="outline"
266
+ aria-label={t("image.remove")}
267
+ disabled={bloqueado}
268
+ // Sem segurar o clique aqui, remover a foto abriria o seletor
269
+ // logo depois — o pior efeito colateral possível para este botão.
270
+ onClick={(event) => {
271
+ event.stopPropagation();
272
+ void remover();
273
+ }}
274
+ >
275
+ <Trash2 className="h-4 w-4" />
276
+ </Button>
277
+ </div>
278
+ )}
279
+
280
+ <input
281
+ ref={inputRef}
282
+ type="file"
283
+ className="hidden"
284
+ // O `accept` vem do perfil: o que o servidor recusa não deveria nem
285
+ // aparecer no seletor.
286
+ accept={info?.accept.join(",")}
287
+ onChange={(event: ChangeEvent<HTMLInputElement>) => {
288
+ void receber(event.target.files?.[0]);
289
+ // Zerar o valor permite escolher o mesmo arquivo de novo depois de
290
+ // remover — sem isto, o `change` não dispara na segunda vez.
291
+ event.target.value = "";
292
+ }}
293
+ />
294
+ </div>
295
+
296
+ {!hideUrlField && (
297
+ <div className="flex items-center gap-2">
298
+ <Input
299
+ value={url}
300
+ disabled={bloqueado}
301
+ placeholder={t("image.urlLabel")}
302
+ onChange={(event) => setUrl(event.target.value)}
303
+ onKeyDown={(event) => {
304
+ if (event.key === "Enter") {
305
+ // Dentro de um formulário, Enter aqui submeteria o cadastro
306
+ // inteiro em vez de buscar a imagem.
307
+ event.preventDefault();
308
+ void buscarEndereco();
309
+ }
310
+ }}
311
+ />
312
+ <Button
313
+ type="button"
314
+ variant="outline"
315
+ size="sm"
316
+ className="shrink-0"
317
+ disabled={bloqueado || url.trim() === ""}
318
+ onClick={() => void buscarEndereco()}
319
+ >
320
+ <Link2 className="mr-1 h-3.5 w-3.5" />
321
+ {t("image.urlAction")}
322
+ </Button>
323
+ </div>
324
+ )}
325
+
326
+ {mensagem && <p className="text-xs text-destructive">{mensagem}</p>}
327
+ </div>
328
+ );
329
+ }
@@ -20,6 +20,7 @@ export * from "./filter-field";
20
20
  export * from "./filter-group";
21
21
  export * from "./filter-rule";
22
22
  export * from "./filter-sheet";
23
+ export * from "./image-upload";
23
24
  export * from "./input";
24
25
  export * from "./job-progress";
25
26
  export * from "./label";
@@ -34,5 +35,6 @@ export * from "./simple-select";
34
35
  export * from "./sonner";
35
36
  export * from "./spinner";
36
37
  export * from "./switch";
38
+ export * from "./tab-shortcuts";
37
39
  export * from "./table";
38
40
  export * from "./tabs";
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import { MAX_TAB_SHORTCUTS, useShortcut } from "#core/hooks/use-shortcut";
6
+
7
+ export interface TabShortcutsProps {
8
+ /** Os valores das abas, na ordem em que aparecem. */
9
+ values: readonly string[];
10
+ onSelect: (value: string) => void;
11
+ }
12
+
13
+ /**
14
+ * O rótulo do atalho de uma aba, para a dica que a própria aba mostra.
15
+ *
16
+ * `⌥` no Mac e `Alt` no resto: é a mesma tecla física, mas ninguém procura
17
+ * "Alt" num teclado da Apple. Fora do navegador (SSR) responde `Alt`, que é o
18
+ * que o primeiro render precisa escrever — a correção vem no cliente.
19
+ */
20
+ export const tabShortcutLabel = (index: number): string => {
21
+ const mac =
22
+ typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
23
+
24
+ return `${mac ? "⌥" : "Alt"} ${index + 1}`;
25
+ };
26
+
27
+ /**
28
+ * Um atalho, uma instância.
29
+ *
30
+ * O `useShortcut` aceita uma lista de teclas mas chama o handler sem dizer
31
+ * **qual** disparou. Com um componente por aba, cada instância fica com a sua
32
+ * tecla e a sua ação, e nenhum hook roda dentro de laço — que é o que a regra
33
+ * dos hooks proíbe.
34
+ */
35
+ const TabShortcut = ({
36
+ digit,
37
+ value,
38
+ onSelect,
39
+ }: {
40
+ digit: number;
41
+ value: string;
42
+ onSelect: (value: string) => void;
43
+ }): null => {
44
+ /*
45
+ Por `code`, e não por `key`: no macOS o `Option` + número não produz o
46
+ número — `⌥1` chega como `¡` e `⌥2` como `™`. Declarado como "1", o atalho
47
+ funcionava no Windows e falhava no Mac, que é o pior desfecho possível.
48
+ */
49
+ useShortcut(`Digit${digit}`, () => onSelect(value), { alt: true, byCode: true });
50
+
51
+ return null;
52
+ };
53
+
54
+ /**
55
+ * `Alt` + `1..9` troca de aba. Não desenha nada.
56
+ *
57
+ * Com `Alt`, e não a tecla sozinha: os números precisam continuar sendo
58
+ * digitáveis, e o `1` sem modificador já é o atalho de abrir cadastro — dois
59
+ * donos para a mesma tecla é o conflito que só aparece na tela onde os dois
60
+ * existem.
61
+ *
62
+ * A ordem das abas é a fonte da numeração: passe a mesma lista que desenha os
63
+ * gatilhos, e não uma cópia. Duas fontes para a mesma ordem é o jeito garantido
64
+ * de `Alt + 3` abrir a aba errada no dia em que alguém reordenar a barra.
65
+ */
66
+ export const TabShortcuts = ({ values, onSelect }: TabShortcutsProps): JSX.Element => (
67
+ <>
68
+ {values.slice(0, MAX_TAB_SHORTCUTS).map((value, index) => (
69
+ <TabShortcut
70
+ key={value}
71
+ digit={index + 1}
72
+ value={value}
73
+ onSelect={onSelect}
74
+ />
75
+ ))}
76
+ </>
77
+ );
@@ -3,25 +3,19 @@
3
3
  import type { JSX } from "react";
4
4
 
5
5
  import { getInitials } from "#core/_utils/initials";
6
+ import { uploadUrlOf } from "#core/_utils/upload-url";
6
7
  import { Avatar, AvatarFallback, AvatarImage } from "#core/components/ui";
7
8
  import { cn } from "#core/lib/utils";
8
9
 
9
10
  /**
10
- * A API guarda o avatar como caminho (`/uploads/avatars/<id>.webp`), servido
11
- * pela origem da API — que não é a da interface. Daí a necessidade de compor a
12
- * URL absoluta aqui, em um lugar só.
11
+ * Monta a URL absoluta do avatar.
12
+ *
13
+ * Segue exportado com este nome porque é assim que as telas o chamam, mas a
14
+ * composição mora em `uploadUrlOf`: avatar é só um caminho de upload entre
15
+ * outros, e duas cópias da mesma regra divergiriam no primeiro ajuste.
13
16
  */
14
- const API_ORIGIN = (process.env.NEXT_PUBLIC_API_BASE_URL ?? "").replace(
15
- /\/api\/v1\/?$/,
16
- "",
17
- );
18
-
19
- /** Monta a URL absoluta do avatar; caminho vazio devolve `undefined`. */
20
17
  export function avatarUrlOf(path?: string | null): string | undefined {
21
- if (!path) {
22
- return undefined;
23
- }
24
- return path.startsWith("http") ? path : `${API_ORIGIN}${path}`;
18
+ return uploadUrlOf(path);
25
19
  }
26
20
 
27
21
  export interface UserAvatarProps {
@@ -3,5 +3,7 @@ export * from "./auth-context";
3
3
  export * from "./brand-context";
4
4
  export * from "./color-mode-context";
5
5
  export * from "./i18n-context";
6
+ export * from "./jobs-context";
7
+ export * from "./jobs-store";
6
8
  export * from "./socket-context";
7
9
  export * from "./toast-context";