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.
- 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 +113 -2
- package/src/components/ui/image-upload.tsx +329 -0
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/tab-shortcuts.tsx +77 -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/features/login/hooks/use-login-flow.ts +1 -1
- package/src/features/notifications/components/notification-item.tsx +34 -10
- package/src/features/notifications/hooks/use-notifications.ts +32 -1
- package/src/features/notifications/notifications-center.tsx +37 -15
- package/src/features/notifications/services/notifications.service.ts +10 -0
- package/src/features/profile/force-password-change.tsx +1 -1
- package/src/features/profile/profile-screen.tsx +4 -2
- package/src/features/recovery/reset-password-screen.tsx +1 -1
- package/src/features/shortcuts/components/shortcut-card.tsx +0 -5
- package/src/features/shortcuts/shortcuts-screen.tsx +14 -3
- package/src/hooks/use-image-upload.ts +169 -0
- package/src/hooks/use-request.ts +47 -5
- package/src/hooks/use-shortcut.ts +52 -3
- package/src/i18n/messages/en.ts +27 -1
- package/src/i18n/messages/pt.ts +27 -1
- package/src/index.ts +32 -0
- package/src/styles/core.css +31 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -49,7 +49,7 @@ export function useLoginFlow(): UseLoginFlowResult {
|
|
|
49
49
|
const router = useRouter();
|
|
50
50
|
const { notify } = useToast();
|
|
51
51
|
const { t } = useI18n();
|
|
52
|
-
const { run, loading, error, setError } = useRequest();
|
|
52
|
+
const { run, loading, error, setError } = useRequest({ silent: true });
|
|
53
53
|
const { refreshProfile } = useAuth();
|
|
54
54
|
|
|
55
55
|
const [step, setStep] = useState<LoginStep>("credentials");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react";
|
|
3
|
+
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
|
|
4
4
|
import type { JSX } from "react";
|
|
5
5
|
|
|
6
6
|
import { formatDateTime } from "#core/_utils/format";
|
|
@@ -23,26 +23,37 @@ const ICONS: Record<NotificationType, JSX.Element> = {
|
|
|
23
23
|
export interface NotificationItemProps {
|
|
24
24
|
notification: UserNotification;
|
|
25
25
|
onSelect: (notification: UserNotification) => void;
|
|
26
|
+
/** Tira esta notificação da caixa. */
|
|
27
|
+
onDismiss: (id: string) => void;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export function NotificationItem({
|
|
29
31
|
notification,
|
|
30
32
|
onSelect,
|
|
33
|
+
onDismiss,
|
|
31
34
|
}: NotificationItemProps): JSX.Element {
|
|
32
|
-
const { locale } = useI18n();
|
|
35
|
+
const { locale, t } = useI18n();
|
|
33
36
|
|
|
34
37
|
return (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
/*
|
|
39
|
+
O `×` é irmão do botão, e não filho: botão dentro de botão é HTML
|
|
40
|
+
inválido, e o clique de um dispararia o outro — descartar abriria a rota
|
|
41
|
+
da notificação no caminho.
|
|
42
|
+
*/
|
|
43
|
+
<div
|
|
38
44
|
className={cn(
|
|
39
|
-
"
|
|
45
|
+
"group/notice relative border-b border-border transition-colors hover:bg-accent",
|
|
40
46
|
!notification.read && "bg-accent/40",
|
|
41
47
|
)}
|
|
42
48
|
>
|
|
43
|
-
<
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={() => onSelect(notification)}
|
|
52
|
+
className="flex w-full gap-3 px-4 py-3 pr-10 text-left"
|
|
53
|
+
>
|
|
54
|
+
<span className="mt-0.5 shrink-0">{ICONS[notification.type]}</span>
|
|
44
55
|
|
|
45
|
-
|
|
56
|
+
<span className="min-w-0 flex-1">
|
|
46
57
|
<span className="flex items-center gap-2">
|
|
47
58
|
<span
|
|
48
59
|
className={cn(
|
|
@@ -63,7 +74,20 @@ export function NotificationItem({
|
|
|
63
74
|
<span className="mt-1 block text-[11px] text-muted-foreground">
|
|
64
75
|
{formatDateTime(notification.createdAt, locale)}
|
|
65
76
|
</span>
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
</span>
|
|
78
|
+
</button>
|
|
79
|
+
|
|
80
|
+
{/* Só no hover: um `×` fixo em cada linha vira ruído numa lista de vinte,
|
|
81
|
+
e o gesto de descartar não é o que se procura ao abrir o sino. */}
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
onClick={() => onDismiss(notification.id)}
|
|
85
|
+
aria-label={t("notifications.dismiss")}
|
|
86
|
+
title={t("notifications.dismiss")}
|
|
87
|
+
className="absolute right-2 top-3 hidden rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground group-hover/notice:block"
|
|
88
|
+
>
|
|
89
|
+
<X className="h-3.5 w-3.5" />
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
68
92
|
);
|
|
69
93
|
}
|
|
@@ -20,6 +20,10 @@ export interface UseNotificationsResult {
|
|
|
20
20
|
fetch: () => Promise<void>;
|
|
21
21
|
markAsRead: (id: string) => Promise<void>;
|
|
22
22
|
markAllAsRead: () => Promise<void>;
|
|
23
|
+
/** Tira uma da caixa deste usuário. */
|
|
24
|
+
dismiss: (id: string) => Promise<void>;
|
|
25
|
+
/** Limpa as já lidas — o "não deixa acumular". */
|
|
26
|
+
dismissRead: () => Promise<void>;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export function useNotifications(): UseNotificationsResult {
|
|
@@ -74,5 +78,32 @@ export function useNotifications(): UseNotificationsResult {
|
|
|
74
78
|
await run(() => notificationsService.markAllAsRead());
|
|
75
79
|
}, [run]);
|
|
76
80
|
|
|
77
|
-
|
|
81
|
+
/*
|
|
82
|
+
A lista recarrega em vez de tirar o item na mão: a paginação é do servidor,
|
|
83
|
+
e remover local deixaria a página com um registro a menos até o próximo
|
|
84
|
+
fetch — some uma notificação da contagem sem que ela tenha sido descartada.
|
|
85
|
+
*/
|
|
86
|
+
const dismiss = useCallback(
|
|
87
|
+
async (id: string): Promise<void> => {
|
|
88
|
+
await notificationsService.dismiss(id);
|
|
89
|
+
await fetch();
|
|
90
|
+
},
|
|
91
|
+
[fetch],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const dismissRead = useCallback(async (): Promise<void> => {
|
|
95
|
+
await notificationsService.dismissRead();
|
|
96
|
+
await fetch();
|
|
97
|
+
}, [fetch]);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
items,
|
|
101
|
+
unread,
|
|
102
|
+
loading,
|
|
103
|
+
fetch,
|
|
104
|
+
markAsRead,
|
|
105
|
+
markAllAsRead,
|
|
106
|
+
dismiss,
|
|
107
|
+
dismissRead,
|
|
108
|
+
};
|
|
78
109
|
}
|