rl-core-front 0.15.5 → 0.16.0

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.15.5",
3
+ "version": "0.16.0",
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",
@@ -886,6 +886,86 @@ export interface paths {
886
886
  patch?: never;
887
887
  trace?: never;
888
888
  };
889
+ "/images/profiles": {
890
+ parameters: {
891
+ query?: never;
892
+ header?: never;
893
+ path?: never;
894
+ cookie?: never;
895
+ };
896
+ get: operations["ImagesController_listProfiles"];
897
+ put?: never;
898
+ post?: never;
899
+ delete?: never;
900
+ options?: never;
901
+ head?: never;
902
+ patch?: never;
903
+ trace?: never;
904
+ };
905
+ "/images/profiles/{name}": {
906
+ parameters: {
907
+ query?: never;
908
+ header?: never;
909
+ path?: never;
910
+ cookie?: never;
911
+ };
912
+ get: operations["ImagesController_getProfile"];
913
+ put?: never;
914
+ post?: never;
915
+ delete?: never;
916
+ options?: never;
917
+ head?: never;
918
+ patch?: never;
919
+ trace?: never;
920
+ };
921
+ "/images/{profile}": {
922
+ parameters: {
923
+ query?: never;
924
+ header?: never;
925
+ path?: never;
926
+ cookie?: never;
927
+ };
928
+ get?: never;
929
+ put?: never;
930
+ post: operations["ImagesController_upload"];
931
+ delete: operations["ImagesController_remove"];
932
+ options?: never;
933
+ head?: never;
934
+ patch?: never;
935
+ trace?: never;
936
+ };
937
+ "/images/{profile}/from-url": {
938
+ parameters: {
939
+ query?: never;
940
+ header?: never;
941
+ path?: never;
942
+ cookie?: never;
943
+ };
944
+ get?: never;
945
+ put?: never;
946
+ post: operations["ImagesController_uploadFromUrl"];
947
+ delete?: never;
948
+ options?: never;
949
+ head?: never;
950
+ patch?: never;
951
+ trace?: never;
952
+ };
953
+ "/images/{jobId}/status": {
954
+ parameters: {
955
+ query?: never;
956
+ header?: never;
957
+ path?: never;
958
+ cookie?: never;
959
+ };
960
+ get: operations["ImagesController_status"];
961
+ put?: never;
962
+ post?: never;
963
+ delete?: never;
964
+ options?: never;
965
+ head?: never;
966
+ patch?: never;
967
+ trace?: never;
968
+ };
889
969
  "/jobs/{id}": {
890
970
  parameters: {
891
971
  query?: never;
@@ -1527,6 +1607,60 @@ export interface components {
1527
1607
  errorMessage: string | null;
1528
1608
  };
1529
1609
  StreamableFile: Record<string, never>;
1610
+ ImageProfileResponse: {
1611
+ /** @example funkos */
1612
+ name: string;
1613
+ /**
1614
+ * @description Lado máximo em pixels — é teto, não obrigação
1615
+ * @example 400
1616
+ */
1617
+ width: number;
1618
+ /**
1619
+ * @description Lado da caixa em que a imagem cabe. Com `inside`, a altura final segue a proporção da foto — este número é o teto, e é o que a tela usa para desenhar a área de envio
1620
+ * @example 400
1621
+ */
1622
+ height?: number | null;
1623
+ /**
1624
+ * @example contain
1625
+ * @enum {string}
1626
+ */
1627
+ fit: "contain" | "cover" | "inside";
1628
+ /**
1629
+ * @example webp
1630
+ * @enum {string}
1631
+ */
1632
+ format: "webp" | "jpeg" | "png";
1633
+ /** @example 8388608 */
1634
+ maxBytes: number;
1635
+ /**
1636
+ * @description Vira o `accept` do seletor de arquivo
1637
+ * @example [
1638
+ * "image/jpeg",
1639
+ * "image/png",
1640
+ * "image/webp"
1641
+ * ]
1642
+ */
1643
+ accept: string[];
1644
+ };
1645
+ ImageAcceptedResponse: {
1646
+ /** @example 312 */
1647
+ jobId: string | null;
1648
+ /** @example /uploads/funkos/pending/6f1b2c34-9d55-4a1e-8f0a-2b7c1d9e4f10.webp */
1649
+ path: string | null;
1650
+ };
1651
+ UploadImageFromUrlCommand: {
1652
+ /** @example https://exemplo.com/ron.png */
1653
+ url: string;
1654
+ };
1655
+ ImageStatusResponse: {
1656
+ /** @example completed */
1657
+ state: string;
1658
+ /** @example 100 */
1659
+ percent: number;
1660
+ /** @example /uploads/funkos/pending/6f1b2c34-9d55-4a1e-8f0a-2b7c1d9e4f10.webp */
1661
+ path: string | null;
1662
+ errorMessage: string | null;
1663
+ };
1530
1664
  JobProgressResponse: {
1531
1665
  /** @example 120 */
1532
1666
  processed: number;
@@ -3286,6 +3420,135 @@ export interface operations {
3286
3420
  };
3287
3421
  };
3288
3422
  };
3423
+ ImagesController_listProfiles: {
3424
+ parameters: {
3425
+ query?: never;
3426
+ header?: never;
3427
+ path?: never;
3428
+ cookie?: never;
3429
+ };
3430
+ requestBody?: never;
3431
+ responses: {
3432
+ 200: {
3433
+ headers: {
3434
+ [name: string]: unknown;
3435
+ };
3436
+ content: {
3437
+ "application/json": components["schemas"]["ImageProfileResponse"][];
3438
+ };
3439
+ };
3440
+ };
3441
+ };
3442
+ ImagesController_getProfile: {
3443
+ parameters: {
3444
+ query?: never;
3445
+ header?: never;
3446
+ path: {
3447
+ name: string;
3448
+ };
3449
+ cookie?: never;
3450
+ };
3451
+ requestBody?: never;
3452
+ responses: {
3453
+ 200: {
3454
+ headers: {
3455
+ [name: string]: unknown;
3456
+ };
3457
+ content: {
3458
+ "application/json": components["schemas"]["ImageProfileResponse"];
3459
+ };
3460
+ };
3461
+ };
3462
+ };
3463
+ ImagesController_upload: {
3464
+ parameters: {
3465
+ query?: never;
3466
+ header?: never;
3467
+ path: {
3468
+ profile: string;
3469
+ };
3470
+ cookie?: never;
3471
+ };
3472
+ requestBody?: never;
3473
+ responses: {
3474
+ 202: {
3475
+ headers: {
3476
+ [name: string]: unknown;
3477
+ };
3478
+ content: {
3479
+ "application/json": components["schemas"]["ImageAcceptedResponse"];
3480
+ };
3481
+ };
3482
+ };
3483
+ };
3484
+ ImagesController_remove: {
3485
+ parameters: {
3486
+ query: {
3487
+ path: string;
3488
+ };
3489
+ header?: never;
3490
+ path: {
3491
+ profile: string;
3492
+ };
3493
+ cookie?: never;
3494
+ };
3495
+ requestBody?: never;
3496
+ responses: {
3497
+ /** @description Apagada, ou já não existia */
3498
+ 204: {
3499
+ headers: {
3500
+ [name: string]: unknown;
3501
+ };
3502
+ content?: never;
3503
+ };
3504
+ };
3505
+ };
3506
+ ImagesController_uploadFromUrl: {
3507
+ parameters: {
3508
+ query?: never;
3509
+ header?: never;
3510
+ path: {
3511
+ profile: string;
3512
+ };
3513
+ cookie?: never;
3514
+ };
3515
+ requestBody: {
3516
+ content: {
3517
+ "application/json": components["schemas"]["UploadImageFromUrlCommand"];
3518
+ };
3519
+ };
3520
+ responses: {
3521
+ 202: {
3522
+ headers: {
3523
+ [name: string]: unknown;
3524
+ };
3525
+ content: {
3526
+ "application/json": components["schemas"]["ImageAcceptedResponse"];
3527
+ };
3528
+ };
3529
+ };
3530
+ };
3531
+ ImagesController_status: {
3532
+ parameters: {
3533
+ query?: never;
3534
+ header?: never;
3535
+ path: {
3536
+ jobId: string;
3537
+ };
3538
+ cookie?: never;
3539
+ };
3540
+ requestBody?: never;
3541
+ responses: {
3542
+ 200: {
3543
+ headers: {
3544
+ [name: string]: unknown;
3545
+ };
3546
+ content: {
3547
+ "application/json": components["schemas"]["ImageStatusResponse"];
3548
+ };
3549
+ };
3550
+ };
3551
+ };
3289
3552
  JobStatusController_status: {
3290
3553
  parameters: {
3291
3554
  query?: never;
@@ -3310,7 +3573,7 @@ export interface operations {
3310
3573
  QueueAdminController_counts: {
3311
3574
  parameters: {
3312
3575
  query?: {
3313
- queue?: "jobs" | "exports";
3576
+ queue?: "jobs" | "exports" | "images";
3314
3577
  };
3315
3578
  header?: never;
3316
3579
  path?: never;
@@ -3336,7 +3599,7 @@ export interface operations {
3336
3599
  limit: string;
3337
3600
  sortBy?: "name" | "attemptsMade" | "percent" | "createdAt" | "failedReason";
3338
3601
  sortDir?: "ASC" | "DESC";
3339
- queue?: "jobs" | "exports";
3602
+ queue?: "jobs" | "exports" | "images";
3340
3603
  };
3341
3604
  header?: never;
3342
3605
  path?: never;
@@ -3357,7 +3620,7 @@ export interface operations {
3357
3620
  QueueAdminController_retry: {
3358
3621
  parameters: {
3359
3622
  query?: {
3360
- queue?: "jobs" | "exports";
3623
+ queue?: "jobs" | "exports" | "images";
3361
3624
  };
3362
3625
  header?: never;
3363
3626
  path: {
@@ -3378,7 +3641,7 @@ export interface operations {
3378
3641
  QueueAdminController_remove: {
3379
3642
  parameters: {
3380
3643
  query?: {
3381
- queue?: "jobs" | "exports";
3644
+ queue?: "jobs" | "exports" | "images";
3382
3645
  };
3383
3646
  header?: never;
3384
3647
  path: {
@@ -0,0 +1,81 @@
1
+ import { api } from "#core/_services/api/axios.factory";
2
+
3
+ /** Como a imagem é encaixada no tamanho do perfil (espelha o backend). */
4
+ export enum ImageFit {
5
+ CONTAIN = "contain",
6
+ COVER = "cover",
7
+ INSIDE = "inside",
8
+ }
9
+
10
+ export enum ImageFormat {
11
+ WEBP = "webp",
12
+ JPEG = "jpeg",
13
+ PNG = "png",
14
+ }
15
+
16
+ /**
17
+ * O formato de saída, como o servidor o declara.
18
+ *
19
+ * É a fonte única do tamanho: a tela pergunta e obedece, e é isso que permite
20
+ * trocar 400×400 por 600×600 sem publicar interface nenhuma.
21
+ */
22
+ export interface ImageProfileInfo {
23
+ name: string;
24
+ width: number;
25
+ height: number | null;
26
+ fit: ImageFit;
27
+ format: ImageFormat;
28
+ maxBytes: number;
29
+ accept: string[];
30
+ }
31
+
32
+ /**
33
+ * O que a rota de upload devolve.
34
+ *
35
+ * Com `jobId`, a conversão está na fila e o desfecho chega pelo socket; com
36
+ * `path`, ela já terminou — ambiente sem fila, ou imagem que ficou pronta antes
37
+ * de a resposta HTTP voltar.
38
+ */
39
+ export interface ImageAccepted {
40
+ jobId: string | null;
41
+ path: string | null;
42
+ }
43
+
44
+ /** O resultado da conversão, como o evento de conclusão o traz. */
45
+ export interface ImageJobSummary {
46
+ path: string;
47
+ width: number;
48
+ height: number;
49
+ bytes: number;
50
+ }
51
+
52
+ export const imagesService = {
53
+ profile: (name: string): Promise<ImageProfileInfo> =>
54
+ api.get<ImageProfileInfo>(`/images/profiles/${name}`).then((r) => r.data),
55
+
56
+ profiles: (): Promise<ImageProfileInfo[]> =>
57
+ api.get<ImageProfileInfo[]>("/images/profiles").then((r) => r.data),
58
+
59
+ /** Arquivo escolhido, arrastado ou colado — os três chegam por aqui. */
60
+ upload: (profile: string, file: File): Promise<ImageAccepted> => {
61
+ const form = new FormData();
62
+ form.append("file", file);
63
+
64
+ return api
65
+ .post<ImageAccepted>(`/images/${profile}`, form, {
66
+ headers: { "Content-Type": "multipart/form-data" },
67
+ })
68
+ .then((r) => r.data);
69
+ },
70
+
71
+ /** Endereço colado: quem baixa, converte e guarda é o servidor. */
72
+ fromUrl: (profile: string, url: string): Promise<ImageAccepted> =>
73
+ api
74
+ .post<ImageAccepted>(`/images/${profile}/from-url`, { url })
75
+ .then((r) => r.data),
76
+
77
+ remove: (profile: string, path: string): Promise<void> =>
78
+ api
79
+ .delete(`/images/${profile}`, { params: { path } })
80
+ .then(() => undefined),
81
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Origem da API, sem o prefixo de versão.
3
+ *
4
+ * A API guarda a imagem como caminho (`/uploads/funkos/<id>.webp`) e a serve na
5
+ * própria origem — que não é a da interface. Compor a URL absoluta é
6
+ * necessário, e fica em um lugar só.
7
+ */
8
+ const API_ORIGIN = (process.env.NEXT_PUBLIC_API_BASE_URL ?? "").replace(
9
+ /\/api\/v1\/?$/,
10
+ "",
11
+ );
12
+
13
+ /**
14
+ * Monta a URL absoluta de um arquivo enviado.
15
+ *
16
+ * Caminho vazio devolve `undefined` — é o que `<img src>` espera para não
17
+ * tentar carregar nada. Valor que já é absoluto passa direto: é o caso do
18
+ * avatar gravado por versões anteriores e de qualquer endereço externo que
19
+ * ainda esteja em base.
20
+ */
21
+ export function uploadUrlOf(path?: string | null): string | undefined {
22
+ if (!path) {
23
+ return undefined;
24
+ }
25
+
26
+ return path.startsWith("http") ? path : `${API_ORIGIN}${path}`;
27
+ }
@@ -3,6 +3,7 @@ export * from "./app-shell";
3
3
  export * from "./brand-header";
4
4
  export * from "./brand-panel";
5
5
  export * from "./breadcrumbs";
6
+ export * from "./job-dock";
6
7
  export * from "./language-selector";
7
8
  export * from "./password-field";
8
9
  export * from "./theme-toggle";
@@ -0,0 +1,118 @@
1
+ "use client";
2
+
3
+ import { ChevronDown, ChevronUp, X } from "lucide-react";
4
+ import type { JSX } from "react";
5
+ import { useState } from "react";
6
+
7
+ import { useI18n } from "#core/contexts/i18n-context";
8
+ import { useJobs } from "#core/contexts/jobs-context";
9
+ import { cn } from "#core/lib/utils";
10
+
11
+ /**
12
+ * O painel dos trabalhos em segundo plano, no canto de baixo.
13
+ *
14
+ * Portado do `lw-tagvirtual`, onde este desenho já roda em produção. Três
15
+ * decisões vêm de lá e são o que o torna confiável:
16
+ *
17
+ * - **mora no provider**, não na tela: sair da página não interrompe o
18
+ * acompanhamento, e é justamente aí que um upload demorado costuma estar;
19
+ * - **desmonta quando a lista esvazia** — sem lista, sem componente. Esconder
20
+ * por classe é como nasce o painel que fica encalhado na tela;
21
+ * - **colapsa deslizando**, mantendo a faixa do título clicável: quem quer só
22
+ * tirar isso da frente não precisa cancelar nada.
23
+ *
24
+ * Ele não conhece imagem, planilha nem relatório: recebe `{ label, percent,
25
+ * state }` e serve para qualquer job da fila.
26
+ */
27
+ export function JobDock(): JSX.Element | null {
28
+ const { jobs, dismiss } = useJobs();
29
+ const { t } = useI18n();
30
+ const [open, setOpen] = useState(true);
31
+
32
+ if (jobs.length === 0) {
33
+ return null;
34
+ }
35
+
36
+ const failed = jobs.some((job) => job.state === "failed");
37
+
38
+ return (
39
+ <div
40
+ className={cn(
41
+ "fixed bottom-0 right-2 z-50 h-fit w-72 overflow-hidden rounded-t-lg border border-border shadow-lg transition-transform duration-150",
42
+ !open && "translate-y-[calc(100%-2rem)]",
43
+ )}
44
+ >
45
+ <button
46
+ type="button"
47
+ onClick={() => setOpen(!open)}
48
+ className={cn(
49
+ "flex h-8 w-full items-center justify-between px-3 text-xs font-medium",
50
+ failed
51
+ ? "bg-destructive text-destructive-foreground"
52
+ : "bg-primary text-primary-foreground",
53
+ )}
54
+ >
55
+ <span className="truncate">
56
+ {failed ? t("job.dockFailed") : t("job.dockRunning", { count: jobs.length })}
57
+ </span>
58
+ {open ? (
59
+ <ChevronDown className="h-3.5 w-3.5 shrink-0" />
60
+ ) : (
61
+ <ChevronUp className="h-3.5 w-3.5 shrink-0" />
62
+ )}
63
+ </button>
64
+
65
+ <div className="flex flex-col gap-2 bg-card px-3 py-2">
66
+ {jobs.map((job) => (
67
+ <div key={job.localId} className="space-y-1">
68
+ <div className="flex items-center gap-2">
69
+ <span className="min-w-0 flex-1 truncate text-xs text-foreground">
70
+ {job.label}
71
+ </span>
72
+ {job.state === "failed" ? (
73
+ <button
74
+ type="button"
75
+ aria-label={t("common.close")}
76
+ onClick={() => dismiss(job.localId)}
77
+ className="shrink-0 text-muted-foreground hover:text-foreground"
78
+ >
79
+ <X className="h-3.5 w-3.5" />
80
+ </button>
81
+ ) : (
82
+ <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
83
+ {job.percent}%
84
+ </span>
85
+ )}
86
+ </div>
87
+
88
+ <div className="h-2 w-full overflow-hidden rounded-full bg-muted">
89
+ <div
90
+ className={cn(
91
+ "h-full rounded-full transition-[width] duration-300",
92
+ job.state === "failed" ? "bg-destructive" : "bg-primary",
93
+ // Sem etapa contável, a barra fica cheia e listrada: dizer
94
+ // "ainda estou aqui" é honesto, inventar 47% não é.
95
+ job.state === "running" && job.percent === 0 && "rl-job-stripes",
96
+ )}
97
+ style={{
98
+ width: job.state === "running" && job.percent === 0 ? "100%" : `${job.percent}%`,
99
+ }}
100
+ />
101
+ </div>
102
+
103
+ {(job.error ?? job.message) && (
104
+ <span
105
+ className={cn(
106
+ "block truncate text-[11px]",
107
+ job.error ? "text-destructive" : "text-muted-foreground",
108
+ )}
109
+ >
110
+ {job.error ?? job.message}
111
+ </span>
112
+ )}
113
+ </div>
114
+ ))}
115
+ </div>
116
+ </div>
117
+ );
118
+ }