rl-core-front 0.15.6 → 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/image-upload.tsx +329 -0
- package/src/components/ui/index.ts +1 -0
- 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-image-upload.ts +169 -0
- package/src/i18n/messages/en.ts +16 -0
- package/src/i18n/messages/pt.ts +16 -0
- package/src/index.ts +29 -0
- package/src/styles/core.css +31 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A máquina dos trabalhos em segundo plano, **fora do React**.
|
|
3
|
+
*
|
|
4
|
+
* Mora aqui, e não em `useState` dentro do provider, por dois motivos que
|
|
5
|
+
* andam juntos: o estado é mutável e recebe muitos eventos (progresso chega em
|
|
6
|
+
* rajada), e ele precisa sobreviver a re-render sem recriar timers nem
|
|
7
|
+
* promessas. Com a máquina separada, o provider vira uma casca — e esta parte,
|
|
8
|
+
* que é onde mora a regra, fica testável sem montar componente.
|
|
9
|
+
*
|
|
10
|
+
* Desenho portado do `lw-tagvirtual`, onde roda em produção. As guardas que
|
|
11
|
+
* vieram de lá estão todas aqui: sessão que morre no desfecho, evento sem
|
|
12
|
+
* sessão guardado para reaplicar, prazos para nada ficar eterno e tolerância
|
|
13
|
+
* com o socket fora.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Prazo para o job dar o primeiro sinal depois de enfileirado. */
|
|
17
|
+
export const START_TIMEOUT_MS = 30_000;
|
|
18
|
+
/** Prazo entre um sinal e o próximo. */
|
|
19
|
+
export const PROGRESS_TIMEOUT_MS = 45_000;
|
|
20
|
+
/**
|
|
21
|
+
* Quanto se espera com o socket fora antes de desistir.
|
|
22
|
+
*
|
|
23
|
+
* Enquanto o transporte está caído o job **continua correndo** no servidor:
|
|
24
|
+
* desistir aqui apagaria da vista um trabalho vivo.
|
|
25
|
+
*/
|
|
26
|
+
export const OFFLINE_GRACE_MS = 300_000;
|
|
27
|
+
/** Quanto tempo o job concluído fica visível antes de sair sozinho. */
|
|
28
|
+
export const SUCCESS_LINGER_MS = 1_500;
|
|
29
|
+
/**
|
|
30
|
+
* Progresso que chega antes de o POST responder não tem sessão para onde ir.
|
|
31
|
+
* Guardar e reaplicar no bind evita perder o evento de fim de um trabalho
|
|
32
|
+
* rápido — no upload de imagem, esse é o caso comum.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_BUFFERED_EVENTS = 50;
|
|
35
|
+
|
|
36
|
+
export type TrackedJobState = "running" | "completed" | "failed";
|
|
37
|
+
|
|
38
|
+
/** Um trabalho em andamento, do jeito que o painel o mostra. */
|
|
39
|
+
export interface TrackedJob {
|
|
40
|
+
/** Id local, que existe desde antes de o servidor responder. */
|
|
41
|
+
localId: string;
|
|
42
|
+
jobId: string | null;
|
|
43
|
+
label: string;
|
|
44
|
+
state: TrackedJobState;
|
|
45
|
+
/** Inteiro de 0 a 100. Fica em 0 enquanto o job não reporta etapa. */
|
|
46
|
+
percent: number;
|
|
47
|
+
message: string | null;
|
|
48
|
+
error: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** O que quem inicia um trabalho recebe de volta. */
|
|
52
|
+
export interface JobHandle<TSummary = unknown> {
|
|
53
|
+
localId: string;
|
|
54
|
+
/** Chamado quando a resposta HTTP traz o `jobId`. */
|
|
55
|
+
bind: (jobId: string) => void;
|
|
56
|
+
/** Encerra com resultado — para o trabalho que terminou na própria resposta. */
|
|
57
|
+
settle: (summary: TSummary) => void;
|
|
58
|
+
fail: (message: string) => void;
|
|
59
|
+
/** Resolve no desfecho: é o que o `await` de quem chamou espera. */
|
|
60
|
+
done: Promise<TSummary>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface JobProgressData {
|
|
64
|
+
jobId: string;
|
|
65
|
+
percent: number;
|
|
66
|
+
message?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface JobCompletedData<TSummary = unknown> {
|
|
70
|
+
jobId: string;
|
|
71
|
+
summary: TSummary;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface JobFailedData {
|
|
75
|
+
jobId: string;
|
|
76
|
+
message: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** O controle de uma sessão — nada disto chega à tela. */
|
|
80
|
+
interface Control {
|
|
81
|
+
resolve: (summary: unknown) => void;
|
|
82
|
+
reject: (error: Error) => void;
|
|
83
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
84
|
+
/** Desde quando o socket está fora; zero quando está de pé. */
|
|
85
|
+
offlineSince: number;
|
|
86
|
+
finished: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const MENSAGEM_SEM_RESPOSTA =
|
|
90
|
+
"Sem resposta do servidor. Recarregue para conferir se terminou";
|
|
91
|
+
|
|
92
|
+
export class JobsStore {
|
|
93
|
+
private jobs = new Map<string, TrackedJob>();
|
|
94
|
+
private controls = new Map<string, Control>();
|
|
95
|
+
/** `jobId` do servidor → id local. */
|
|
96
|
+
private index = new Map<string, string>();
|
|
97
|
+
private buffered = new Map<string, Array<() => void>>();
|
|
98
|
+
private listeners = new Set<() => void>();
|
|
99
|
+
private snapshot: TrackedJob[] = [];
|
|
100
|
+
private offline = false;
|
|
101
|
+
|
|
102
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
103
|
+
this.listeners.add(listener);
|
|
104
|
+
|
|
105
|
+
return () => this.listeners.delete(listener);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* O mesmo array enquanto nada muda.
|
|
110
|
+
*
|
|
111
|
+
* `useSyncExternalStore` compara por identidade e entra em laço infinito se
|
|
112
|
+
* cada leitura devolver um array novo.
|
|
113
|
+
*/
|
|
114
|
+
getSnapshot = (): TrackedJob[] => this.snapshot;
|
|
115
|
+
|
|
116
|
+
/** Registra uma sessão. O `jobId` chega depois, no `bind`. */
|
|
117
|
+
start = <TSummary,>(label: string): JobHandle<TSummary> => {
|
|
118
|
+
const localId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
119
|
+
let resolve: (summary: TSummary) => void = () => undefined;
|
|
120
|
+
let reject: (error: Error) => void = () => undefined;
|
|
121
|
+
|
|
122
|
+
const done = new Promise<TSummary>((res, rej) => {
|
|
123
|
+
resolve = res;
|
|
124
|
+
reject = rej;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
this.jobs.set(localId, {
|
|
128
|
+
localId,
|
|
129
|
+
jobId: null,
|
|
130
|
+
label,
|
|
131
|
+
state: "running",
|
|
132
|
+
percent: 0,
|
|
133
|
+
message: null,
|
|
134
|
+
error: null,
|
|
135
|
+
});
|
|
136
|
+
this.controls.set(localId, {
|
|
137
|
+
resolve: resolve as (summary: unknown) => void,
|
|
138
|
+
reject,
|
|
139
|
+
timer: null,
|
|
140
|
+
offlineSince: this.offline ? Date.now() : 0,
|
|
141
|
+
finished: false,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
this.arm(localId, START_TIMEOUT_MS);
|
|
145
|
+
this.publish();
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
localId,
|
|
149
|
+
done,
|
|
150
|
+
bind: (jobId: string) => this.bind(localId, jobId),
|
|
151
|
+
settle: (summary: TSummary) => this.settle(localId, summary),
|
|
152
|
+
fail: (message: string) => this.fail(localId, message),
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Tira da tela um job que o usuário fechou. */
|
|
157
|
+
dismiss = (localId: string): void => {
|
|
158
|
+
this.forget(localId);
|
|
159
|
+
this.publish();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
onProgress = (data: JobProgressData): void => {
|
|
163
|
+
this.withSession(data.jobId, (localId) => {
|
|
164
|
+
this.patch(localId, {
|
|
165
|
+
state: "running",
|
|
166
|
+
percent: data.percent,
|
|
167
|
+
message: data.message ?? null,
|
|
168
|
+
});
|
|
169
|
+
this.arm(localId, PROGRESS_TIMEOUT_MS);
|
|
170
|
+
this.publish();
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
onCompleted = <TSummary,>(data: JobCompletedData<TSummary>): void => {
|
|
175
|
+
this.withSession(data.jobId, (localId) => this.settle(localId, data.summary));
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
onFailed = (data: JobFailedData): void => {
|
|
179
|
+
this.withSession(data.jobId, (localId) => this.fail(localId, data.message));
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Marca o transporte como fora ou de volta.
|
|
184
|
+
*
|
|
185
|
+
* Com o socket fora, o prazo passa a ser o de tolerância: o trabalho segue
|
|
186
|
+
* do outro lado, e o que se perde é só a notícia dele.
|
|
187
|
+
*/
|
|
188
|
+
setOffline = (offline: boolean): void => {
|
|
189
|
+
this.offline = offline;
|
|
190
|
+
|
|
191
|
+
for (const control of this.controls.values()) {
|
|
192
|
+
control.offlineSince = offline ? control.offlineSince || Date.now() : 0;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Os jobs que já têm id no servidor — os que a reconciliação consulta. */
|
|
197
|
+
pendingJobIds = (): string[] =>
|
|
198
|
+
[...this.jobs.values()]
|
|
199
|
+
.filter((job) => job.jobId && !this.controls.get(job.localId)?.finished)
|
|
200
|
+
.map((job) => job.jobId as string);
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Aplica o que o `GET /jobs/:id` respondeu.
|
|
204
|
+
*
|
|
205
|
+
* É a rede de segurança do socket: o que passou enquanto a conexão esteve
|
|
206
|
+
* fora não volta sozinho.
|
|
207
|
+
*/
|
|
208
|
+
reconcile = <TSummary,>(
|
|
209
|
+
jobId: string,
|
|
210
|
+
state: string,
|
|
211
|
+
percent: number | null,
|
|
212
|
+
message: string | null,
|
|
213
|
+
summary: TSummary | null,
|
|
214
|
+
errorMessage: string | null,
|
|
215
|
+
): void => {
|
|
216
|
+
this.withSession(
|
|
217
|
+
jobId,
|
|
218
|
+
(localId) => {
|
|
219
|
+
if (state === "completed") {
|
|
220
|
+
this.settle(localId, summary);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (state === "failed") {
|
|
224
|
+
this.fail(localId, errorMessage ?? "Falha ao processar");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.patch(localId, {
|
|
229
|
+
percent: percent ?? this.jobs.get(localId)?.percent ?? 0,
|
|
230
|
+
message: message ?? this.jobs.get(localId)?.message ?? null,
|
|
231
|
+
});
|
|
232
|
+
this.publish();
|
|
233
|
+
},
|
|
234
|
+
// Sem buffer: reconciliação de job que esta aba não acompanha não
|
|
235
|
+
// interessa a ninguém, e guardá-la seguraria memória sem consumidor.
|
|
236
|
+
false,
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** Solta timers e sessões — chamado quando o provider desmonta. */
|
|
241
|
+
destroy = (): void => {
|
|
242
|
+
this.controls.forEach((control) => this.clearTimer(control));
|
|
243
|
+
this.controls.clear();
|
|
244
|
+
this.jobs.clear();
|
|
245
|
+
this.index.clear();
|
|
246
|
+
this.buffered.clear();
|
|
247
|
+
this.publish();
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
private bind(localId: string, jobId: string): void {
|
|
251
|
+
if (!this.jobs.has(localId)) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
this.patch(localId, { jobId });
|
|
256
|
+
this.index.set(jobId, localId);
|
|
257
|
+
this.arm(localId, PROGRESS_TIMEOUT_MS);
|
|
258
|
+
this.publish();
|
|
259
|
+
|
|
260
|
+
// O que chegou antes de existir sessão vale agora.
|
|
261
|
+
const buffered = this.buffered.get(jobId);
|
|
262
|
+
this.buffered.delete(jobId);
|
|
263
|
+
buffered?.forEach((replay) => replay());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private settle(localId: string, summary: unknown): void {
|
|
267
|
+
const control = this.controls.get(localId);
|
|
268
|
+
|
|
269
|
+
if (!control || control.finished) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
control.finished = true;
|
|
274
|
+
this.clearTimer(control);
|
|
275
|
+
this.patch(localId, { state: "completed", percent: 100, error: null });
|
|
276
|
+
control.resolve(summary);
|
|
277
|
+
this.publish();
|
|
278
|
+
|
|
279
|
+
// Sai da lista por remoção, e não por uma classe que o esconde: painel que
|
|
280
|
+
// "fica escondido" é exatamente como nasce o painel encalhado.
|
|
281
|
+
setTimeout(() => {
|
|
282
|
+
this.forget(localId);
|
|
283
|
+
this.publish();
|
|
284
|
+
}, SUCCESS_LINGER_MS).unref?.();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private fail(localId: string, message: string): void {
|
|
288
|
+
const control = this.controls.get(localId);
|
|
289
|
+
|
|
290
|
+
if (!control || control.finished) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
control.finished = true;
|
|
295
|
+
this.clearTimer(control);
|
|
296
|
+
this.patch(localId, { state: "failed", error: message });
|
|
297
|
+
control.reject(new Error(message));
|
|
298
|
+
// Falha **não** sai sozinha: é a única forma de a pessoa saber o motivo se
|
|
299
|
+
// já tiver saído da tela que pediu. Sai no `dismiss`.
|
|
300
|
+
this.publish();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Resolve a sessão de um `jobId` e executa a ação.
|
|
305
|
+
*
|
|
306
|
+
* Sem sessão, a ação vai para o buffer daquele `jobId` — o caso do evento que
|
|
307
|
+
* chega antes de o POST responder.
|
|
308
|
+
*/
|
|
309
|
+
private withSession(
|
|
310
|
+
jobId: string,
|
|
311
|
+
action: (localId: string) => void,
|
|
312
|
+
buffer = true,
|
|
313
|
+
): void {
|
|
314
|
+
const localId = this.index.get(jobId);
|
|
315
|
+
|
|
316
|
+
if (localId && this.jobs.has(localId)) {
|
|
317
|
+
action(localId);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (!buffer) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const pendentes = this.buffered.get(jobId) ?? [];
|
|
325
|
+
|
|
326
|
+
if (pendentes.length < MAX_BUFFERED_EVENTS) {
|
|
327
|
+
pendentes.push(() => this.withSession(jobId, action, false));
|
|
328
|
+
this.buffered.set(jobId, pendentes);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private arm(localId: string, delay: number): void {
|
|
333
|
+
const control = this.controls.get(localId);
|
|
334
|
+
|
|
335
|
+
if (!control) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
this.clearTimer(control);
|
|
340
|
+
control.timer = setTimeout(() => {
|
|
341
|
+
const atual = this.controls.get(localId);
|
|
342
|
+
|
|
343
|
+
if (!atual || atual.finished) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// Socket fora não é motivo para desistir: o job segue no servidor.
|
|
347
|
+
if (atual.offlineSince > 0 && Date.now() - atual.offlineSince < OFFLINE_GRACE_MS) {
|
|
348
|
+
this.arm(localId, PROGRESS_TIMEOUT_MS);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this.fail(localId, MENSAGEM_SEM_RESPOSTA);
|
|
353
|
+
}, delay);
|
|
354
|
+
control.timer.unref?.();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private clearTimer(control: Control): void {
|
|
358
|
+
if (control.timer) {
|
|
359
|
+
clearTimeout(control.timer);
|
|
360
|
+
control.timer = null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private patch(localId: string, changes: Partial<TrackedJob>): void {
|
|
365
|
+
const job = this.jobs.get(localId);
|
|
366
|
+
|
|
367
|
+
if (job) {
|
|
368
|
+
this.jobs.set(localId, { ...job, ...changes });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private forget(localId: string): void {
|
|
373
|
+
const job = this.jobs.get(localId);
|
|
374
|
+
const control = this.controls.get(localId);
|
|
375
|
+
|
|
376
|
+
if (control) {
|
|
377
|
+
this.clearTimer(control);
|
|
378
|
+
}
|
|
379
|
+
if (job?.jobId) {
|
|
380
|
+
this.index.delete(job.jobId);
|
|
381
|
+
this.buffered.delete(job.jobId);
|
|
382
|
+
}
|
|
383
|
+
this.jobs.delete(localId);
|
|
384
|
+
this.controls.delete(localId);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private publish(): void {
|
|
388
|
+
this.snapshot = [...this.jobs.values()];
|
|
389
|
+
this.listeners.forEach((listener) => listener());
|
|
390
|
+
}
|
|
391
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ImageAccepted,
|
|
7
|
+
ImageJobSummary,
|
|
8
|
+
ImageProfileInfo,
|
|
9
|
+
imagesService,
|
|
10
|
+
} from "#core/_services/images";
|
|
11
|
+
import { useI18n } from "#core/contexts/i18n-context";
|
|
12
|
+
import { useJobs } from "#core/contexts/jobs-context";
|
|
13
|
+
|
|
14
|
+
export interface UseImageUploadResult {
|
|
15
|
+
/** O formato de saída que o servidor declarou. `null` enquanto carrega. */
|
|
16
|
+
profile: ImageProfileInfo | null;
|
|
17
|
+
sending: boolean;
|
|
18
|
+
error: string | null;
|
|
19
|
+
/** Envia um arquivo e devolve o caminho quando a conversão termina. */
|
|
20
|
+
upload: (file: File) => Promise<string | null>;
|
|
21
|
+
/** Manda o servidor buscar a imagem num endereço. */
|
|
22
|
+
uploadFromUrl: (url: string) => Promise<string | null>;
|
|
23
|
+
clearError: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Orquestra o envio de uma imagem: pergunta o perfil, valida o que dá para
|
|
28
|
+
* validar antes de gastar rede, envia e espera o desfecho.
|
|
29
|
+
*
|
|
30
|
+
* A espera não abre socket nenhum: ela registra uma sessão no `JobsProvider`,
|
|
31
|
+
* que já escuta a conexão do `AppShell` — e é a mesma sessão que o painel do
|
|
32
|
+
* rodapé mostra. Quando a resposta já traz o `path` (imagem pequena, ou
|
|
33
|
+
* ambiente sem fila), o job se encerra na hora, sem esperar evento que não vem.
|
|
34
|
+
*/
|
|
35
|
+
export function useImageUpload(profileName: string): UseImageUploadResult {
|
|
36
|
+
const { t } = useI18n();
|
|
37
|
+
const { start } = useJobs();
|
|
38
|
+
const [profile, setProfile] = useState<ImageProfileInfo | null>(null);
|
|
39
|
+
const [sending, setSending] = useState(false);
|
|
40
|
+
const [error, setError] = useState<string | null>(null);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let ativo = true;
|
|
44
|
+
|
|
45
|
+
void imagesService
|
|
46
|
+
.profile(profileName)
|
|
47
|
+
.then((data) => {
|
|
48
|
+
if (ativo) {
|
|
49
|
+
setProfile(data);
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
.catch(() => {
|
|
53
|
+
// Perfil que não responde não derruba a tela: o envio segue possível, e
|
|
54
|
+
// quem recusa de verdade é o servidor.
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
ativo = false;
|
|
59
|
+
};
|
|
60
|
+
}, [profileName]);
|
|
61
|
+
|
|
62
|
+
/** O caminho comum aos dois envios: sessão, espera e tradução do erro. */
|
|
63
|
+
const send = useCallback(
|
|
64
|
+
async (
|
|
65
|
+
label: string,
|
|
66
|
+
request: () => Promise<ImageAccepted>,
|
|
67
|
+
): Promise<string | null> => {
|
|
68
|
+
setSending(true);
|
|
69
|
+
setError(null);
|
|
70
|
+
|
|
71
|
+
const job = start<ImageJobSummary>(label);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const accepted = await request();
|
|
75
|
+
|
|
76
|
+
// Já terminou: encerra a sessão com o resultado em vez de esperar um
|
|
77
|
+
// evento que não vai chegar.
|
|
78
|
+
if (accepted.path) {
|
|
79
|
+
job.settle({ path: accepted.path, width: 0, height: 0, bytes: 0 });
|
|
80
|
+
return accepted.path;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!accepted.jobId) {
|
|
84
|
+
throw new Error(t("image.failed"));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
job.bind(accepted.jobId);
|
|
88
|
+
|
|
89
|
+
const summary = await job.done;
|
|
90
|
+
return summary.path;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const message = messageOf(err, t("image.failed"));
|
|
93
|
+
|
|
94
|
+
// `fail` é idempotente do ponto de vista de quem chama: se o job já
|
|
95
|
+
// terminou em falha (foi o socket que trouxe o erro), isto não duplica
|
|
96
|
+
// nada — mas se a falha foi no próprio HTTP, é o que tira a sessão do
|
|
97
|
+
// painel.
|
|
98
|
+
job.fail(message);
|
|
99
|
+
setError(message);
|
|
100
|
+
return null;
|
|
101
|
+
} finally {
|
|
102
|
+
setSending(false);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
[start, t],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const upload = useCallback(
|
|
109
|
+
async (file: File): Promise<string | null> => {
|
|
110
|
+
// As duas validações que dá para fazer aqui, e que evitam subir megabytes
|
|
111
|
+
// para receber 400: tipo e tamanho, ambos vindos do perfil.
|
|
112
|
+
if (profile && !profile.accept.includes(file.type)) {
|
|
113
|
+
setError(t("image.invalidType"));
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (profile && file.size > profile.maxBytes) {
|
|
117
|
+
setError(
|
|
118
|
+
t("image.tooLarge", {
|
|
119
|
+
size: Math.round(profile.maxBytes / (1024 * 1024)),
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return await send(file.name, () => imagesService.upload(profileName, file));
|
|
126
|
+
},
|
|
127
|
+
[profile, profileName, send, t],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const uploadFromUrl = useCallback(
|
|
131
|
+
async (url: string): Promise<string | null> =>
|
|
132
|
+
await send(labelOf(url), () => imagesService.fromUrl(profileName, url)),
|
|
133
|
+
[profileName, send],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
profile,
|
|
138
|
+
sending,
|
|
139
|
+
error,
|
|
140
|
+
upload,
|
|
141
|
+
uploadFromUrl,
|
|
142
|
+
clearError: () => setError(null),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** O nome no fim da URL, para rotular a barra. */
|
|
147
|
+
const labelOf = (url: string): string =>
|
|
148
|
+
url.split("?")[0].split("/").filter(Boolean).pop()?.slice(0, 60) ?? "imagem";
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A mensagem que o backend escreveu, quando existe.
|
|
152
|
+
*
|
|
153
|
+
* O core devolve texto pronto para ler em erro de negócio ("A imagem passa do
|
|
154
|
+
* limite de 8MB"), e trocá-lo por um genérico esconderia justamente o que a
|
|
155
|
+
* pessoa precisa saber para resolver.
|
|
156
|
+
*/
|
|
157
|
+
const messageOf = (error: unknown, fallback: string): string => {
|
|
158
|
+
const response = (error as { response?: { data?: { message?: unknown } } })
|
|
159
|
+
?.response?.data?.message;
|
|
160
|
+
|
|
161
|
+
if (typeof response === "string") {
|
|
162
|
+
return response;
|
|
163
|
+
}
|
|
164
|
+
if (Array.isArray(response) && typeof response[0] === "string") {
|
|
165
|
+
return response[0];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (error as Error)?.message || fallback;
|
|
169
|
+
};
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -91,6 +91,21 @@ export const en: Messages = {
|
|
|
91
91
|
running: "Processing…",
|
|
92
92
|
completed: "Processing finished",
|
|
93
93
|
failed: "The processing could not be completed",
|
|
94
|
+
dockRunning: "{count} in progress",
|
|
95
|
+
dockFailed: "Something went wrong",
|
|
96
|
+
},
|
|
97
|
+
image: {
|
|
98
|
+
empty: "Drag an image, paste it, or",
|
|
99
|
+
choose: "choose a file",
|
|
100
|
+
urlLabel: "Or paste an image address",
|
|
101
|
+
urlAction: "Fetch",
|
|
102
|
+
remove: "Remove image",
|
|
103
|
+
replace: "Replace image",
|
|
104
|
+
sending: "Sending…",
|
|
105
|
+
tooLarge: "The image exceeds the {size}MB limit",
|
|
106
|
+
invalidType: "Invalid format — use JPEG, PNG or WebP",
|
|
107
|
+
failed: "The image could not be sent",
|
|
108
|
+
hint: "The image is converted to {format} at up to {width}px.",
|
|
94
109
|
},
|
|
95
110
|
queues: {
|
|
96
111
|
title: "Queues",
|
|
@@ -178,6 +193,7 @@ export const en: Messages = {
|
|
|
178
193
|
hint: "Fill in only the fields you want to use. All of them are combined with AND.",
|
|
179
194
|
apply: "Apply",
|
|
180
195
|
clear: "Clear",
|
|
196
|
+
close: "Close",
|
|
181
197
|
empty: "This listing has no filters yet.",
|
|
182
198
|
noOptions: "No options available",
|
|
183
199
|
all: "All",
|
package/src/i18n/messages/pt.ts
CHANGED
|
@@ -93,6 +93,21 @@ export const pt = {
|
|
|
93
93
|
running: "Processando…",
|
|
94
94
|
completed: "Processamento concluído",
|
|
95
95
|
failed: "Não foi possível concluir o processamento",
|
|
96
|
+
dockRunning: "{count} em andamento",
|
|
97
|
+
dockFailed: "Algo não deu certo",
|
|
98
|
+
},
|
|
99
|
+
image: {
|
|
100
|
+
empty: "Arraste uma imagem, cole, ou",
|
|
101
|
+
choose: "escolha um arquivo",
|
|
102
|
+
urlLabel: "Ou cole o endereço de uma imagem",
|
|
103
|
+
urlAction: "Buscar",
|
|
104
|
+
remove: "Remover imagem",
|
|
105
|
+
replace: "Trocar imagem",
|
|
106
|
+
sending: "Enviando…",
|
|
107
|
+
tooLarge: "A imagem passa do limite de {size}MB",
|
|
108
|
+
invalidType: "Formato inválido — use JPEG, PNG ou WebP",
|
|
109
|
+
failed: "Não foi possível enviar a imagem",
|
|
110
|
+
hint: "A imagem é convertida para {format} em até {width}px.",
|
|
96
111
|
},
|
|
97
112
|
queues: {
|
|
98
113
|
title: "Filas",
|
|
@@ -181,6 +196,7 @@ export const pt = {
|
|
|
181
196
|
hint: "Preencha só os campos que quiser usar. Tudo é combinado com E.",
|
|
182
197
|
apply: "Aplicar",
|
|
183
198
|
clear: "Limpar",
|
|
199
|
+
close: "Fechar",
|
|
184
200
|
empty: "Esta listagem ainda não tem filtros.",
|
|
185
201
|
noOptions: "Nenhuma opção disponível",
|
|
186
202
|
all: "Todos",
|
package/src/index.ts
CHANGED
|
@@ -87,6 +87,8 @@ export type { UseFilterSchemaResult } from "#core/hooks/use-filter-schema";
|
|
|
87
87
|
export { useFilterSchema } from "#core/hooks/use-filter-schema";
|
|
88
88
|
export type { FilterMode, FiltersState } from "#core/hooks/use-filters";
|
|
89
89
|
export { useFilters } from "#core/hooks/use-filters";
|
|
90
|
+
export type { UseImageUploadResult } from "#core/hooks/use-image-upload";
|
|
91
|
+
export { useImageUpload } from "#core/hooks/use-image-upload";
|
|
90
92
|
export type {
|
|
91
93
|
JobState,
|
|
92
94
|
UseJobProgressResult,
|
|
@@ -125,3 +127,30 @@ export { toListParams } from "#core/_services/api/list-query";
|
|
|
125
127
|
export { en } from "#core/i18n/messages/en";
|
|
126
128
|
export type { Messages } from "#core/i18n/messages/pt";
|
|
127
129
|
export { pt } from "#core/i18n/messages/pt";
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Upload de imagem — o componente, o serviço e o painel de trabalhos
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// O projeto declara o perfil no backend (tamanho, formato, pasta) e usa o
|
|
135
|
+
// componente com o nome dele: `<ImageUpload profile="funkos" … />`. O
|
|
136
|
+
// `JobsProvider` entra no layout, dentro do `SocketProvider` — é ele que
|
|
137
|
+
// mantém o acompanhamento vivo quando a pessoa troca de tela no meio de um
|
|
138
|
+
// upload, e é ele que renderiza o painel do rodapé.
|
|
139
|
+
export type {
|
|
140
|
+
ImageAccepted,
|
|
141
|
+
ImageJobSummary,
|
|
142
|
+
ImageProfileInfo,
|
|
143
|
+
} from "#core/_services/images";
|
|
144
|
+
export { ImageFit, ImageFormat, imagesService } from "#core/_services/images";
|
|
145
|
+
export { uploadUrlOf } from "#core/_utils/upload-url";
|
|
146
|
+
export { JobDock } from "#core/components/job-dock";
|
|
147
|
+
export type { ImageUploadProps } from "#core/components/ui/image-upload";
|
|
148
|
+
export { ImageUpload } from "#core/components/ui/image-upload";
|
|
149
|
+
export type { JobsContextType } from "#core/contexts/jobs-context";
|
|
150
|
+
export { JobsProvider, useJobs } from "#core/contexts/jobs-context";
|
|
151
|
+
export type {
|
|
152
|
+
JobHandle,
|
|
153
|
+
TrackedJob,
|
|
154
|
+
TrackedJobState,
|
|
155
|
+
} from "#core/contexts/jobs-store";
|
|
156
|
+
export { JobsStore } from "#core/contexts/jobs-store";
|
package/src/styles/core.css
CHANGED
|
@@ -237,3 +237,34 @@
|
|
|
237
237
|
width: 0%;
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
|
+
|
|
241
|
+
/*
|
|
242
|
+
* Listras andando na barra de progresso do painel de jobs.
|
|
243
|
+
*
|
|
244
|
+
* Existe porque um job de imagem não tem etapas contáveis: ele passa de "estou
|
|
245
|
+
* baixando" para "acabei" sem números no meio, e uma barra parada em 50% é
|
|
246
|
+
* indistinguível de uma barra travada. As listras dizem "ainda estou aqui" sem
|
|
247
|
+
* fingir precisão que o job não tem.
|
|
248
|
+
*/
|
|
249
|
+
.rl-job-stripes {
|
|
250
|
+
background-image: linear-gradient(
|
|
251
|
+
45deg,
|
|
252
|
+
rgb(255 255 255 / 0.3) 25%,
|
|
253
|
+
transparent 25%,
|
|
254
|
+
transparent 50%,
|
|
255
|
+
rgb(255 255 255 / 0.3) 50%,
|
|
256
|
+
rgb(255 255 255 / 0.3) 75%,
|
|
257
|
+
transparent 75%,
|
|
258
|
+
transparent
|
|
259
|
+
);
|
|
260
|
+
background-size: 1rem 1rem;
|
|
261
|
+
animation: rl-job-stripes 1s linear infinite;
|
|
262
|
+
}
|
|
263
|
+
@keyframes rl-job-stripes {
|
|
264
|
+
from {
|
|
265
|
+
background-position: 0 0;
|
|
266
|
+
}
|
|
267
|
+
to {
|
|
268
|
+
background-position: 1rem 0;
|
|
269
|
+
}
|
|
270
|
+
}
|