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 +1 -1
- package/src/_services/api/schema.d.ts +267 -4
- package/src/_services/images/index.ts +81 -0
- package/src/_utils/upload-url.ts +27 -0
- package/src/components/index.ts +1 -0
- package/src/components/job-dock.tsx +118 -0
- package/src/components/ui/data-table.tsx +600 -250
- package/src/components/ui/image-upload.tsx +329 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/table.tsx +30 -1
- package/src/components/user-avatar.tsx +7 -13
- package/src/contexts/index.ts +2 -0
- package/src/contexts/jobs-context.tsx +146 -0
- package/src/contexts/jobs-store.ts +391 -0
- package/src/hooks/use-column-visibility.ts +6 -1
- package/src/hooks/use-image-upload.ts +169 -0
- package/src/hooks/use-table-view.ts +54 -0
- package/src/i18n/messages/en.ts +20 -0
- package/src/i18n/messages/pt.ts +20 -0
- package/src/index.ts +31 -0
- package/src/styles/core.css +31 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
+
}
|