rl-core-front 0.18.7 → 0.18.8
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/axios.factory.ts +3 -0
- package/src/_utils/storage.ts +31 -0
- package/src/components/app-shell.tsx +173 -114
- package/src/components/ui/column-visibility-modal.tsx +11 -4
- package/src/components/ui/filter-sheet.tsx +9 -3
- package/src/contexts/auth-context.tsx +6 -1
- package/src/contexts/color-mode-context.tsx +29 -22
- package/src/contexts/i18n-context.tsx +5 -15
- package/src/contexts/socket-context.tsx +10 -1
- package/src/features/audit/components/record-audit-button.tsx +3 -1
- package/src/features/audit/hooks/use-audit-trail.ts +3 -1
- package/src/features/logs/hooks/use-request-logs.ts +3 -1
- package/src/features/notifications/hooks/use-notifications.ts +3 -1
- package/src/features/profile/profile-screen.tsx +3 -1
- package/src/features/queues/hooks/use-queue-jobs.ts +3 -1
- package/src/features/rbac/panels/catalog-panel.tsx +3 -1
- package/src/features/rbac/panels/groups-panel.tsx +3 -1
- package/src/features/rbac/panels/permissions-panel.tsx +3 -1
- package/src/features/rbac/panels/roles-panel.tsx +8 -4
- package/src/features/recovery/reset-password-screen.tsx +17 -5
- package/src/features/users/hooks/use-users.ts +3 -1
- package/src/hooks/use-column-visibility.ts +30 -18
- package/src/hooks/use-document-title.ts +49 -18
- package/src/hooks/use-filter-schema.ts +3 -1
- package/src/hooks/use-filters.ts +36 -10
- package/src/hooks/use-is-hydrated.ts +21 -0
- package/src/hooks/use-job-progress.ts +11 -4
- package/src/hooks/use-list-query.ts +9 -4
- package/src/hooks/use-stored-value.ts +35 -0
- package/src/hooks/use-tab-state.ts +62 -28
- package/src/hooks/use-table-view.ts +15 -17
package/package.json
CHANGED
|
@@ -57,6 +57,9 @@ export class AxiosFactory {
|
|
|
57
57
|
// middleware ainda vê os cookies (inválidos, mas presentes) e manda de volta
|
|
58
58
|
// pro dashboard, gerando loop entre /dashboard e /login?reason=expired.
|
|
59
59
|
await api.post("/auth/logout").catch(() => {});
|
|
60
|
+
// Fora de componente não há `router`, e a recarga é o que se quer:
|
|
61
|
+
// a sessão venceu e o que está em memória não vale mais.
|
|
62
|
+
// eslint-disable-next-line @next/next/no-location-assign-relative-destination
|
|
60
63
|
window.location.href = "/login?reason=expired";
|
|
61
64
|
}
|
|
62
65
|
}
|
package/src/_utils/storage.ts
CHANGED
|
@@ -18,6 +18,15 @@
|
|
|
18
18
|
*/
|
|
19
19
|
const memory = new Map<string, string>();
|
|
20
20
|
|
|
21
|
+
/** Quem quer saber que uma chave mudou. */
|
|
22
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
23
|
+
|
|
24
|
+
const notify = (key: string): void => {
|
|
25
|
+
for (const listener of listeners.get(key) ?? []) {
|
|
26
|
+
listener();
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
21
30
|
/**
|
|
22
31
|
* Espelho em memória para quando o navegador recusa o armazenamento.
|
|
23
32
|
*
|
|
@@ -48,6 +57,7 @@ export const safeStorage = {
|
|
|
48
57
|
// Cota estourada ou armazenamento negado: guarda na sessão e segue.
|
|
49
58
|
memory.set(key, value);
|
|
50
59
|
}
|
|
60
|
+
notify(key);
|
|
51
61
|
},
|
|
52
62
|
|
|
53
63
|
remove(key: string): void {
|
|
@@ -60,5 +70,26 @@ export const safeStorage = {
|
|
|
60
70
|
} catch {
|
|
61
71
|
// Já saiu do espelho em memória; não há mais nada a fazer.
|
|
62
72
|
}
|
|
73
|
+
notify(key);
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Avisa quando uma chave muda, para quem lê por `useSyncExternalStore`.
|
|
78
|
+
*
|
|
79
|
+
* É o que permite a preferência salva ser lida direto no render, em vez de
|
|
80
|
+
* copiada para um `useState` dentro de um efeito: quem escreve é este mesmo
|
|
81
|
+
* módulo, então a notificação sai daqui.
|
|
82
|
+
*/
|
|
83
|
+
subscribe(key: string, listener: () => void): () => void {
|
|
84
|
+
const current = listeners.get(key) ?? new Set<() => void>();
|
|
85
|
+
current.add(listener);
|
|
86
|
+
listeners.set(key, current);
|
|
87
|
+
|
|
88
|
+
return () => {
|
|
89
|
+
current.delete(listener);
|
|
90
|
+
if (current.size === 0) {
|
|
91
|
+
listeners.delete(key);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
63
94
|
},
|
|
64
95
|
};
|
|
@@ -212,6 +212,164 @@ export interface AppShellProps {
|
|
|
212
212
|
forbidden?: React.ReactNode;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* O que os blocos do menu precisam do shell para se desenhar.
|
|
217
|
+
*
|
|
218
|
+
* Eles moram aqui fora, e não dentro do `AppShell`, porque componente criado
|
|
219
|
+
* no render é um tipo novo a cada render: o React desmontava e remontava o
|
|
220
|
+
* menu inteiro a cada clique, perdendo foco e a rolagem da lista.
|
|
221
|
+
*/
|
|
222
|
+
interface NavRender {
|
|
223
|
+
pathname: string;
|
|
224
|
+
t: (key: string) => string;
|
|
225
|
+
go: (href: string) => void;
|
|
226
|
+
isGroupOpen: (group: NavGroup) => boolean;
|
|
227
|
+
toggleGroup: (group: NavGroup) => void;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const NavLink = ({
|
|
231
|
+
item,
|
|
232
|
+
mini,
|
|
233
|
+
nested,
|
|
234
|
+
parentKey,
|
|
235
|
+
nav,
|
|
236
|
+
}: {
|
|
237
|
+
item: NavItem;
|
|
238
|
+
mini: boolean;
|
|
239
|
+
nested?: boolean;
|
|
240
|
+
/**
|
|
241
|
+
* Grupo a que o item pertence, só para o tooltip do modo recolhido.
|
|
242
|
+
*
|
|
243
|
+
* Ali os filhos aparecem soltos, sem o pai: "Catálogos" sozinho, ao lado de
|
|
244
|
+
* "Tarefas", não diria de quê. O caminho no tooltip resolve isso sem
|
|
245
|
+
* obrigar o rótulo a repetir o nome da feature no menu aberto.
|
|
246
|
+
*/
|
|
247
|
+
parentKey?: string;
|
|
248
|
+
nav: NavRender;
|
|
249
|
+
}): JSX.Element => {
|
|
250
|
+
const { pathname, t, go } = nav;
|
|
251
|
+
const active = pathname === item.href;
|
|
252
|
+
const Icon = item.icon;
|
|
253
|
+
const tooltip = parentKey
|
|
254
|
+
? `${t(parentKey)} › ${t(item.key)}`
|
|
255
|
+
: t(item.key);
|
|
256
|
+
return (
|
|
257
|
+
<button
|
|
258
|
+
onClick={() => go(item.href)}
|
|
259
|
+
title={mini ? tooltip : undefined}
|
|
260
|
+
className={cn(
|
|
261
|
+
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
262
|
+
mini && "justify-center px-2",
|
|
263
|
+
nested && !mini && "pl-10",
|
|
264
|
+
active
|
|
265
|
+
? "bg-primary/10 text-primary"
|
|
266
|
+
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
267
|
+
)}
|
|
268
|
+
>
|
|
269
|
+
<Icon className="h-5 w-5 shrink-0" />
|
|
270
|
+
{!mini && <span className="truncate">{t(item.key)}</span>}
|
|
271
|
+
</button>
|
|
272
|
+
);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Um grupo do menu — o pai que abre e fecha, e os filhos indentados.
|
|
277
|
+
*
|
|
278
|
+
* Com a barra recolhida não há onde desenhar seta nem rótulo, então os
|
|
279
|
+
* filhos aparecem soltos, separados do resto; é o que o "Administração" já
|
|
280
|
+
* fazia, e agora vale para qualquer grupo.
|
|
281
|
+
*/
|
|
282
|
+
const NavGroupBlock = ({
|
|
283
|
+
group,
|
|
284
|
+
mini,
|
|
285
|
+
nav,
|
|
286
|
+
}: {
|
|
287
|
+
group: NavGroup;
|
|
288
|
+
mini: boolean;
|
|
289
|
+
nav: NavRender;
|
|
290
|
+
}): JSX.Element => {
|
|
291
|
+
const { t, isGroupOpen, toggleGroup } = nav;
|
|
292
|
+
const Icon = group.icon;
|
|
293
|
+
const open = isGroupOpen(group);
|
|
294
|
+
if (mini) {
|
|
295
|
+
return (
|
|
296
|
+
<>
|
|
297
|
+
<Separator className="my-2" />
|
|
298
|
+
{group.children.map((child) => (
|
|
299
|
+
<NavLink
|
|
300
|
+
key={child.href}
|
|
301
|
+
item={child}
|
|
302
|
+
mini
|
|
303
|
+
parentKey={group.key}
|
|
304
|
+
nav={nav}
|
|
305
|
+
/>
|
|
306
|
+
))}
|
|
307
|
+
</>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return (
|
|
311
|
+
<div className="mt-2">
|
|
312
|
+
<button
|
|
313
|
+
onClick={() => toggleGroup(group)}
|
|
314
|
+
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
315
|
+
>
|
|
316
|
+
<Icon className="h-5 w-5 shrink-0" />
|
|
317
|
+
<span className="flex-1 text-left">{t(group.key)}</span>
|
|
318
|
+
<ChevronRight
|
|
319
|
+
className={cn("h-4 w-4 transition-transform", open && "rotate-90")}
|
|
320
|
+
/>
|
|
321
|
+
</button>
|
|
322
|
+
{open && (
|
|
323
|
+
<div className="mt-1 flex flex-col gap-1">
|
|
324
|
+
{group.children.map((child) => (
|
|
325
|
+
<NavLink
|
|
326
|
+
key={child.href}
|
|
327
|
+
item={child}
|
|
328
|
+
mini={false}
|
|
329
|
+
nested
|
|
330
|
+
nav={nav}
|
|
331
|
+
/>
|
|
332
|
+
))}
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const NavContent = ({
|
|
340
|
+
mini,
|
|
341
|
+
nav,
|
|
342
|
+
projectEntries,
|
|
343
|
+
adminChildren,
|
|
344
|
+
adminGroup,
|
|
345
|
+
}: {
|
|
346
|
+
mini: boolean;
|
|
347
|
+
nav: NavRender;
|
|
348
|
+
projectEntries: NavEntry[];
|
|
349
|
+
adminChildren: NavItem[];
|
|
350
|
+
adminGroup: NavGroup;
|
|
351
|
+
}): JSX.Element => (
|
|
352
|
+
<div className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden p-3">
|
|
353
|
+
<NavLink item={DASHBOARD} mini={mini} nav={nav} />
|
|
354
|
+
|
|
355
|
+
{projectEntries.map((entry) =>
|
|
356
|
+
isGroup(entry) ? (
|
|
357
|
+
<NavGroupBlock key={entry.key} group={entry} mini={mini} nav={nav} />
|
|
358
|
+
) : (
|
|
359
|
+
<NavLink key={entry.href} item={entry} mini={mini} nav={nav} />
|
|
360
|
+
),
|
|
361
|
+
)}
|
|
362
|
+
|
|
363
|
+
{adminChildren.length > 0 && (
|
|
364
|
+
<NavGroupBlock group={adminGroup} mini={mini} nav={nav} />
|
|
365
|
+
)}
|
|
366
|
+
|
|
367
|
+
<div className="mt-1">
|
|
368
|
+
<NavGroupBlock group={SETTINGS_GROUP} mini={mini} nav={nav} />
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
|
|
215
373
|
export function AppShell({
|
|
216
374
|
children,
|
|
217
375
|
navItems,
|
|
@@ -364,118 +522,7 @@ export function AppShell({
|
|
|
364
522
|
setMobileOpen(false);
|
|
365
523
|
};
|
|
366
524
|
|
|
367
|
-
const
|
|
368
|
-
item,
|
|
369
|
-
mini,
|
|
370
|
-
nested,
|
|
371
|
-
parentKey,
|
|
372
|
-
}: {
|
|
373
|
-
item: NavItem;
|
|
374
|
-
mini: boolean;
|
|
375
|
-
nested?: boolean;
|
|
376
|
-
/**
|
|
377
|
-
* Grupo a que o item pertence, só para o tooltip do modo recolhido.
|
|
378
|
-
*
|
|
379
|
-
* Ali os filhos aparecem soltos, sem o pai: "Catálogos" sozinho, ao lado de
|
|
380
|
-
* "Tarefas", não diria de quê. O caminho no tooltip resolve isso sem
|
|
381
|
-
* obrigar o rótulo a repetir o nome da feature no menu aberto.
|
|
382
|
-
*/
|
|
383
|
-
parentKey?: string;
|
|
384
|
-
}): JSX.Element => {
|
|
385
|
-
const active = pathname === item.href;
|
|
386
|
-
const Icon = item.icon;
|
|
387
|
-
const tooltip = parentKey
|
|
388
|
-
? `${t(parentKey)} › ${t(item.key)}`
|
|
389
|
-
: t(item.key);
|
|
390
|
-
return (
|
|
391
|
-
<button
|
|
392
|
-
onClick={() => go(item.href)}
|
|
393
|
-
title={mini ? tooltip : undefined}
|
|
394
|
-
className={cn(
|
|
395
|
-
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
396
|
-
mini && "justify-center px-2",
|
|
397
|
-
nested && !mini && "pl-10",
|
|
398
|
-
active
|
|
399
|
-
? "bg-primary/10 text-primary"
|
|
400
|
-
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
401
|
-
)}
|
|
402
|
-
>
|
|
403
|
-
<Icon className="h-5 w-5 shrink-0" />
|
|
404
|
-
{!mini && <span className="truncate">{t(item.key)}</span>}
|
|
405
|
-
</button>
|
|
406
|
-
);
|
|
407
|
-
};
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Um grupo do menu — o pai que abre e fecha, e os filhos indentados.
|
|
411
|
-
*
|
|
412
|
-
* Com a barra recolhida não há onde desenhar seta nem rótulo, então os
|
|
413
|
-
* filhos aparecem soltos, separados do resto; é o que o "Administração" já
|
|
414
|
-
* fazia, e agora vale para qualquer grupo.
|
|
415
|
-
*/
|
|
416
|
-
const NavGroupBlock = ({
|
|
417
|
-
group,
|
|
418
|
-
mini,
|
|
419
|
-
}: {
|
|
420
|
-
group: NavGroup;
|
|
421
|
-
mini: boolean;
|
|
422
|
-
}): JSX.Element => {
|
|
423
|
-
const Icon = group.icon;
|
|
424
|
-
const open = isGroupOpen(group);
|
|
425
|
-
if (mini) {
|
|
426
|
-
return (
|
|
427
|
-
<>
|
|
428
|
-
<Separator className="my-2" />
|
|
429
|
-
{group.children.map((child) => (
|
|
430
|
-
<NavLink key={child.href} item={child} mini parentKey={group.key} />
|
|
431
|
-
))}
|
|
432
|
-
</>
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
return (
|
|
436
|
-
<div className="mt-2">
|
|
437
|
-
<button
|
|
438
|
-
onClick={() => toggleGroup(group)}
|
|
439
|
-
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
440
|
-
>
|
|
441
|
-
<Icon className="h-5 w-5 shrink-0" />
|
|
442
|
-
<span className="flex-1 text-left">{t(group.key)}</span>
|
|
443
|
-
<ChevronRight
|
|
444
|
-
className={cn("h-4 w-4 transition-transform", open && "rotate-90")}
|
|
445
|
-
/>
|
|
446
|
-
</button>
|
|
447
|
-
{open && (
|
|
448
|
-
<div className="mt-1 flex flex-col gap-1">
|
|
449
|
-
{group.children.map((child) => (
|
|
450
|
-
<NavLink key={child.href} item={child} mini={false} nested />
|
|
451
|
-
))}
|
|
452
|
-
</div>
|
|
453
|
-
)}
|
|
454
|
-
</div>
|
|
455
|
-
);
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
const NavContent = ({ mini }: { mini: boolean }): JSX.Element => (
|
|
459
|
-
<div className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden p-3">
|
|
460
|
-
<NavLink item={DASHBOARD} mini={mini} />
|
|
461
|
-
|
|
462
|
-
{projectEntries.map((entry) =>
|
|
463
|
-
isGroup(entry) ? (
|
|
464
|
-
<NavGroupBlock key={entry.key} group={entry} mini={mini} />
|
|
465
|
-
) : (
|
|
466
|
-
<NavLink key={entry.href} item={entry} mini={mini} />
|
|
467
|
-
),
|
|
468
|
-
)}
|
|
469
|
-
|
|
470
|
-
{adminChildren.length > 0 && (
|
|
471
|
-
<NavGroupBlock group={adminGroup} mini={mini} />
|
|
472
|
-
)}
|
|
473
|
-
|
|
474
|
-
<div className="mt-1">
|
|
475
|
-
<NavGroupBlock group={SETTINGS_GROUP} mini={mini} />
|
|
476
|
-
</div>
|
|
477
|
-
</div>
|
|
478
|
-
);
|
|
525
|
+
const nav: NavRender = { pathname, t, go, isGroupOpen, toggleGroup };
|
|
479
526
|
|
|
480
527
|
return (
|
|
481
528
|
<div className="min-h-screen bg-background">
|
|
@@ -557,7 +604,13 @@ export function AppShell({
|
|
|
557
604
|
collapsed ? "w-[76px]" : "w-64",
|
|
558
605
|
)}
|
|
559
606
|
>
|
|
560
|
-
<NavContent
|
|
607
|
+
<NavContent
|
|
608
|
+
mini={collapsed}
|
|
609
|
+
nav={nav}
|
|
610
|
+
projectEntries={projectEntries}
|
|
611
|
+
adminChildren={adminChildren}
|
|
612
|
+
adminGroup={adminGroup}
|
|
613
|
+
/>
|
|
561
614
|
</aside>
|
|
562
615
|
|
|
563
616
|
{/* Mobile sidebar */}
|
|
@@ -572,7 +625,13 @@ export function AppShell({
|
|
|
572
625
|
{brand.mark}
|
|
573
626
|
<span className="text-lg font-extrabold">{brand.name}</span>
|
|
574
627
|
</div>
|
|
575
|
-
<NavContent
|
|
628
|
+
<NavContent
|
|
629
|
+
mini={false}
|
|
630
|
+
nav={nav}
|
|
631
|
+
projectEntries={projectEntries}
|
|
632
|
+
adminChildren={adminChildren}
|
|
633
|
+
adminGroup={adminGroup}
|
|
634
|
+
/>
|
|
576
635
|
</aside>
|
|
577
636
|
</div>
|
|
578
637
|
)}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { JSX } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { useState } from "react";
|
|
5
5
|
|
|
6
6
|
import { Button } from "#core/components/ui/button";
|
|
7
7
|
import {
|
|
@@ -37,10 +37,17 @@ export function ColumnVisibilityModal({
|
|
|
37
37
|
}: Props): JSX.Element {
|
|
38
38
|
const { t } = useI18n();
|
|
39
39
|
const [draftHidden, setDraftHidden] = useState<string[]>(hiddenColumns);
|
|
40
|
+
const [wasOpen, setWasOpen] = useState(open);
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Abrir recomeça o rascunho do que a tabela mostra hoje. O ajuste é no
|
|
43
|
+
// render, e não num efeito: assim o modal já nasce com as colunas certas,
|
|
44
|
+
// sem o quadro intermediário com a escolha da vez anterior.
|
|
45
|
+
if (open !== wasOpen) {
|
|
46
|
+
setWasOpen(open);
|
|
47
|
+
if (open) {
|
|
48
|
+
setDraftHidden(hiddenColumns);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
44
51
|
|
|
45
52
|
const isVisible = (id: string): boolean => !draftHidden.includes(id);
|
|
46
53
|
const visibleCount = columns.length - draftHidden.length;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { JSX } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { useState } from "react";
|
|
5
5
|
|
|
6
6
|
import type { AdvancedGroup } from "#core/_utils/advanced-filter";
|
|
7
7
|
import { countConditions, initialTree } from "#core/_utils/advanced-filter";
|
|
@@ -77,13 +77,19 @@ export function FilterSheet({
|
|
|
77
77
|
const [draftTree, setDraftTree] = useState<AdvancedGroup | null>(tree);
|
|
78
78
|
const [draftMode, setDraftMode] = useState<FilterMode>(mode);
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
const [wasOpen, setWasOpen] = useState(open);
|
|
81
|
+
|
|
82
|
+
// Abrir recomeça os rascunhos do filtro que está valendo. O ajuste é no
|
|
83
|
+
// render, e não num efeito: o painel já nasce com o filtro atual, sem o
|
|
84
|
+
// quadro intermediário com o que foi digitado da última vez.
|
|
85
|
+
if (open !== wasOpen) {
|
|
86
|
+
setWasOpen(open);
|
|
81
87
|
if (open) {
|
|
82
88
|
setDraft(values);
|
|
83
89
|
setDraftTree(tree);
|
|
84
90
|
setDraftMode(mode);
|
|
85
91
|
}
|
|
86
|
-
}
|
|
92
|
+
}
|
|
87
93
|
|
|
88
94
|
const setField = (field: string, value: FilterDraftValue): void => {
|
|
89
95
|
setDraft((current) => ({ ...current, [field]: value }));
|
|
@@ -43,6 +43,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }): JSX.E
|
|
|
43
43
|
await authService.logout();
|
|
44
44
|
} finally {
|
|
45
45
|
setUser(null);
|
|
46
|
+
// Recarga de verdade, e não `router.push`: sair tem que descartar o que
|
|
47
|
+
// ficou em memória — perfil, permissões e o socket da sessão anterior.
|
|
48
|
+
// eslint-disable-next-line @next/next/no-location-assign-relative-destination
|
|
46
49
|
if (typeof window !== "undefined") {window.location.href = "/login";}
|
|
47
50
|
}
|
|
48
51
|
}, []);
|
|
@@ -63,7 +66,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }): JSX.E
|
|
|
63
66
|
);
|
|
64
67
|
|
|
65
68
|
useEffect(() => {
|
|
66
|
-
void
|
|
69
|
+
void (async (): Promise<void> => {
|
|
70
|
+
await refreshProfile();
|
|
71
|
+
})();
|
|
67
72
|
}, [refreshProfile]);
|
|
68
73
|
|
|
69
74
|
return (
|
|
@@ -7,10 +7,12 @@ import {
|
|
|
7
7
|
useContext,
|
|
8
8
|
useEffect,
|
|
9
9
|
useMemo,
|
|
10
|
-
|
|
10
|
+
useSyncExternalStore,
|
|
11
11
|
} from "react";
|
|
12
12
|
|
|
13
13
|
import { safeStorage } from "#core/_utils/storage";
|
|
14
|
+
import { useIsHydrated } from "#core/hooks/use-is-hydrated";
|
|
15
|
+
import { useStoredValue } from "#core/hooks/use-stored-value";
|
|
14
16
|
|
|
15
17
|
export const STORAGE_KEY = "color-mode";
|
|
16
18
|
|
|
@@ -35,6 +37,17 @@ function applyClass(mode: Mode): void {
|
|
|
35
37
|
document.documentElement.classList.toggle("dark", mode === "dark");
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** O tema do sistema é de fora do React: avisa quando o usuário o troca. */
|
|
41
|
+
function subscribeSystemMode(onChange: () => void): () => void {
|
|
42
|
+
if (typeof window === "undefined" || !window.matchMedia) {
|
|
43
|
+
return () => {};
|
|
44
|
+
}
|
|
45
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
46
|
+
mq.addEventListener("change", onChange);
|
|
47
|
+
return () => mq.removeEventListener("change", onChange);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
38
51
|
export interface ColorModeContextType {
|
|
39
52
|
mode: Mode;
|
|
40
53
|
preference: ColorPreference;
|
|
@@ -47,27 +60,21 @@ const ColorModeContext = createContext<ColorModeContextType | undefined>(
|
|
|
47
60
|
);
|
|
48
61
|
|
|
49
62
|
export function ColorModeProvider({ children }: { children: React.ReactNode }): JSX.Element {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (isPreference(saved)) {
|
|
61
|
-
setPreferenceState(saved);
|
|
62
|
-
}
|
|
63
|
-
setSysMode(systemMode());
|
|
64
|
-
setResolved(true);
|
|
63
|
+
const saved = useStoredValue(STORAGE_KEY);
|
|
64
|
+
const preference: ColorPreference = isPreference(saved)
|
|
65
|
+
? saved
|
|
66
|
+
: DEFAULT_PREFERENCE;
|
|
67
|
+
|
|
68
|
+
const sysMode = useSyncExternalStore<Mode>(
|
|
69
|
+
subscribeSystemMode,
|
|
70
|
+
systemMode,
|
|
71
|
+
() => "light",
|
|
72
|
+
);
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}, []);
|
|
74
|
+
// Antes de hidratar não se sabe o tema real. O script anti-flash do layout já
|
|
75
|
+
// pôs a classe certa no <html>; aplicar classe antes disso só desfaria o
|
|
76
|
+
// trabalho dele e causava piscada de claro→escuro.
|
|
77
|
+
const resolved = useIsHydrated();
|
|
71
78
|
|
|
72
79
|
const mode: Mode = preference === "system" ? sysMode : preference;
|
|
73
80
|
|
|
@@ -78,7 +85,7 @@ export function ColorModeProvider({ children }: { children: React.ReactNode }):
|
|
|
78
85
|
}, [mode, resolved]);
|
|
79
86
|
|
|
80
87
|
const setPreference = useCallback((p: ColorPreference) => {
|
|
81
|
-
|
|
88
|
+
// Escrever avisa quem lê a chave — inclusive este provider.
|
|
82
89
|
safeStorage.set(STORAGE_KEY, p);
|
|
83
90
|
}, []);
|
|
84
91
|
|
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { JSX } from "react";
|
|
4
|
-
import {
|
|
5
|
-
createContext,
|
|
6
|
-
useCallback,
|
|
7
|
-
useContext,
|
|
8
|
-
useEffect,
|
|
9
|
-
useMemo,
|
|
10
|
-
useState,
|
|
11
|
-
} from "react";
|
|
4
|
+
import { createContext, useCallback, useContext, useMemo } from "react";
|
|
12
5
|
|
|
13
6
|
import { safeStorage } from "#core/_utils/storage";
|
|
7
|
+
import { useStoredValue } from "#core/hooks/use-stored-value";
|
|
14
8
|
import { en } from "#core/i18n/messages/en";
|
|
15
9
|
import { pt } from "#core/i18n/messages/pt";
|
|
16
10
|
|
|
@@ -88,7 +82,8 @@ export function I18nProvider({
|
|
|
88
82
|
children,
|
|
89
83
|
messages,
|
|
90
84
|
}: I18nProviderProps): JSX.Element {
|
|
91
|
-
const
|
|
85
|
+
const saved = useStoredValue(STORAGE_KEY);
|
|
86
|
+
const locale: Locale = saved === "pt" || saved === "en" ? saved : "pt";
|
|
92
87
|
|
|
93
88
|
// Mescla uma vez por dicionário, não a cada tradução: `t` roda em todo render
|
|
94
89
|
// de toda tela que usa texto.
|
|
@@ -100,13 +95,8 @@ export function I18nProvider({
|
|
|
100
95
|
[messages],
|
|
101
96
|
);
|
|
102
97
|
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
const saved = (safeStorage.get(STORAGE_KEY) as Locale) || null;
|
|
105
|
-
if (saved === "pt" || saved === "en") {setLocaleState(saved);}
|
|
106
|
-
}, []);
|
|
107
|
-
|
|
108
98
|
const setLocale = useCallback((l: Locale) => {
|
|
109
|
-
|
|
99
|
+
// Escrever avisa quem lê a chave — inclusive este provider.
|
|
110
100
|
safeStorage.set(STORAGE_KEY, l);
|
|
111
101
|
document.cookie = `${STORAGE_KEY}=${l}; path=/; max-age=31536000`;
|
|
112
102
|
}, []);
|
|
@@ -68,6 +68,7 @@ export function SocketProvider({
|
|
|
68
68
|
}): JSX.Element {
|
|
69
69
|
const { user, refreshProfile } = useAuth();
|
|
70
70
|
const [status, setStatus] = useState<SocketStatus>("disconnected");
|
|
71
|
+
const [lastUserId, setLastUserId] = useState<string | undefined>(undefined);
|
|
71
72
|
const [reconnectCount, setReconnectCount] = useState(0);
|
|
72
73
|
const socketRef = useRef<Socket | null>(null);
|
|
73
74
|
|
|
@@ -85,12 +86,20 @@ export function SocketProvider({
|
|
|
85
86
|
// socket.io entraria em ciclo de retentativa.
|
|
86
87
|
const userId = user?.id;
|
|
87
88
|
|
|
89
|
+
// Quem manda no estado é a sessão: com um usuário novo há uma conexão nova a
|
|
90
|
+
// abrir, e o "conectando" é consequência disso — não do efeito, que só vai
|
|
91
|
+
// rodar depois da pintura e deixaria a barra dizendo "desconectado" por um
|
|
92
|
+
// quadro.
|
|
93
|
+
if (userId !== lastUserId) {
|
|
94
|
+
setLastUserId(userId);
|
|
95
|
+
setStatus(userId ? "connecting" : "disconnected");
|
|
96
|
+
}
|
|
97
|
+
|
|
88
98
|
useEffect(() => {
|
|
89
99
|
if (!userId) {
|
|
90
100
|
return;
|
|
91
101
|
}
|
|
92
102
|
|
|
93
|
-
setStatus("connecting");
|
|
94
103
|
const socket = io(`${SOCKET_URL}${NAMESPACE}`, {
|
|
95
104
|
withCredentials: true,
|
|
96
105
|
transports: ["websocket"],
|
|
@@ -44,7 +44,9 @@ export function useNotifications(): UseNotificationsResult {
|
|
|
44
44
|
// Carga inicial e, de novo, a cada reconexão: enquanto o socket esteve fora
|
|
45
45
|
// chegaram notificações que ninguém empurrou para esta aba.
|
|
46
46
|
useEffect(() => {
|
|
47
|
-
void
|
|
47
|
+
void (async (): Promise<void> => {
|
|
48
|
+
await fetch();
|
|
49
|
+
})();
|
|
48
50
|
}, [fetch, reconnectCount]);
|
|
49
51
|
|
|
50
52
|
// Sem toast aqui: o badge do sino já é o aviso, e disparar os dois pelo mesmo
|