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.
@@ -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";
@@ -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";
@@ -0,0 +1,146 @@
1
+ "use client";
2
+
3
+ import type { JSX, ReactNode } from "react";
4
+ import {
5
+ createContext,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ useSyncExternalStore,
11
+ } from "react";
12
+
13
+ import { api } from "#core/_services/api/axios.factory";
14
+ import { JobDock } from "#core/components/job-dock";
15
+ import {
16
+ JobCompletedData,
17
+ JobFailedData,
18
+ JobHandle,
19
+ JobProgressData,
20
+ JobsStore,
21
+ TrackedJob,
22
+ } from "#core/contexts/jobs-store";
23
+ import { useSocket } from "#core/contexts/socket-context";
24
+
25
+ /** Eventos que a fila do backend empurra (ver `queue.constants.ts` do core). */
26
+ const JOB_PROGRESS_EVENT = "job:progress";
27
+ const JOB_COMPLETED_EVENT = "job:completed";
28
+ const JOB_FAILED_EVENT = "job:failed";
29
+
30
+ /** `GET /jobs/:id` — a mesma informação, para quem perdeu os eventos. */
31
+ interface JobStatusResponse<TSummary> {
32
+ id: string;
33
+ state: "waiting" | "active" | "completed" | "failed" | "delayed" | "unknown";
34
+ progress: { percent: number; message?: string } | null;
35
+ summary: TSummary | null;
36
+ error: { code: string | null; message: string } | null;
37
+ }
38
+
39
+ export interface JobsContextType {
40
+ jobs: TrackedJob[];
41
+ start: <TSummary>(label: string) => JobHandle<TSummary>;
42
+ dismiss: (localId: string) => void;
43
+ }
44
+
45
+ const JobsContext = createContext<JobsContextType | undefined>(undefined);
46
+
47
+ /**
48
+ * Os trabalhos em segundo plano que esta aba está acompanhando.
49
+ *
50
+ * Vive **no provider**, e não na tela — é isso que mantém o acompanhamento
51
+ * quando a pessoa navega para outra rota no meio de um upload. O painel
52
+ * (`JobDock`) é renderizado aqui pelo mesmo motivo.
53
+ *
54
+ * A regra mora na `JobsStore`, fora do React; aqui ficam só as três ligações
55
+ * com o mundo: os eventos do socket, a reconciliação por HTTP quando a conexão
56
+ * volta, e a leitura do estado pela tela.
57
+ */
58
+ export function JobsProvider({ children }: { children: ReactNode }): JSX.Element {
59
+ const { on, status, reconnectCount } = useSocket();
60
+ // Instância estável pelo inicializador do `useState`, e não por um ref: ler
61
+ // `ref.current` durante o render é justamente o que as regras do React 19
62
+ // proíbem — e aqui a store precisa existir já no primeiro render, porque o
63
+ // `useSyncExternalStore` a assina.
64
+ const [store] = useState(() => new JobsStore());
65
+
66
+ const jobs = useSyncExternalStore(
67
+ store.subscribe,
68
+ store.getSnapshot,
69
+ // O servidor renderiza sem job nenhum: a lista só existe nesta aba.
70
+ store.getSnapshot,
71
+ );
72
+
73
+ useEffect(() => {
74
+ store.setOffline(status !== "connected");
75
+ }, [status, store]);
76
+
77
+ useEffect(() => {
78
+ const offProgress = on<JobProgressData>(JOB_PROGRESS_EVENT, store.onProgress);
79
+ const offCompleted = on<JobCompletedData>(
80
+ JOB_COMPLETED_EVENT,
81
+ store.onCompleted,
82
+ );
83
+ const offFailed = on<JobFailedData>(JOB_FAILED_EVENT, store.onFailed);
84
+
85
+ return () => {
86
+ offProgress();
87
+ offCompleted();
88
+ offFailed();
89
+ };
90
+ }, [on, store]);
91
+
92
+ /**
93
+ * Reconciliação a cada reconexão.
94
+ *
95
+ * O que passou enquanto o socket esteve fora não volta sozinho — e é
96
+ * justamente aí que está o job que terminou sem ninguém ver.
97
+ */
98
+ useEffect(() => {
99
+ if (reconnectCount === 0) {
100
+ return;
101
+ }
102
+
103
+ for (const jobId of store.pendingJobIds()) {
104
+ void api
105
+ .get<JobStatusResponse<unknown>>(`/jobs/${jobId}`)
106
+ .then(({ data }) =>
107
+ store.reconcile(
108
+ jobId,
109
+ data.state,
110
+ data.progress?.percent ?? null,
111
+ data.progress?.message ?? null,
112
+ data.summary,
113
+ data.error?.message ?? null,
114
+ ),
115
+ )
116
+ .catch(() => {
117
+ // Job expirado ou fora de alcance: o socket ainda pode trazer o fim,
118
+ // e derrubar a sessão por causa da consulta de apoio seria pior.
119
+ });
120
+ }
121
+ }, [reconnectCount, store]);
122
+
123
+ useEffect(() => () => store.destroy(), [store]);
124
+
125
+ const value = useMemo<JobsContextType>(
126
+ () => ({ jobs, start: store.start, dismiss: store.dismiss }),
127
+ [jobs, store],
128
+ );
129
+
130
+ return (
131
+ <JobsContext.Provider value={value}>
132
+ {children}
133
+ <JobDock />
134
+ </JobsContext.Provider>
135
+ );
136
+ }
137
+
138
+ export function useJobs(): JobsContextType {
139
+ const context = useContext(JobsContext);
140
+
141
+ if (!context) {
142
+ throw new Error("useJobs precisa estar dentro de <JobsProvider>");
143
+ }
144
+
145
+ return context;
146
+ }