rl-core-front 0.18.7 → 0.18.9

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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/_services/api/axios.factory.ts +3 -0
  3. package/src/_utils/storage.ts +31 -0
  4. package/src/components/app-shell.tsx +173 -114
  5. package/src/components/ui/column-visibility-modal.tsx +11 -4
  6. package/src/components/ui/combobox.tsx +46 -0
  7. package/src/components/ui/filter-sheet.tsx +9 -3
  8. package/src/contexts/auth-context.tsx +6 -1
  9. package/src/contexts/color-mode-context.tsx +29 -22
  10. package/src/contexts/i18n-context.tsx +5 -15
  11. package/src/contexts/socket-context.tsx +10 -1
  12. package/src/features/audit/components/record-audit-button.tsx +3 -1
  13. package/src/features/audit/hooks/use-audit-trail.ts +3 -1
  14. package/src/features/logs/hooks/use-request-logs.ts +3 -1
  15. package/src/features/notifications/hooks/use-notifications.ts +3 -1
  16. package/src/features/profile/profile-screen.tsx +3 -1
  17. package/src/features/queues/hooks/use-queue-jobs.ts +3 -1
  18. package/src/features/rbac/panels/catalog-panel.tsx +3 -1
  19. package/src/features/rbac/panels/groups-panel.tsx +3 -1
  20. package/src/features/rbac/panels/permissions-panel.tsx +3 -1
  21. package/src/features/rbac/panels/roles-panel.tsx +8 -4
  22. package/src/features/recovery/reset-password-screen.tsx +17 -5
  23. package/src/features/users/hooks/use-users.ts +3 -1
  24. package/src/hooks/use-column-visibility.ts +30 -18
  25. package/src/hooks/use-document-title.ts +49 -18
  26. package/src/hooks/use-filter-schema.ts +3 -1
  27. package/src/hooks/use-filters.ts +36 -10
  28. package/src/hooks/use-is-hydrated.ts +21 -0
  29. package/src/hooks/use-job-progress.ts +11 -4
  30. package/src/hooks/use-list-query.ts +9 -4
  31. package/src/hooks/use-stored-value.ts +35 -0
  32. package/src/hooks/use-tab-state.ts +62 -28
  33. package/src/hooks/use-table-view.ts +15 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.18.7",
3
+ "version": "0.18.9",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usuários, RBAC, auditoria, logs e listagens com filtro dinâmico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -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
  }
@@ -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 NavLink = ({
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 mini={collapsed} />
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 mini={false} />
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 { useEffect, useState } from "react";
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
- useEffect(() => {
42
- if (open) {setDraftHidden(hiddenColumns);}
43
- }, [open, hiddenColumns]);
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;
@@ -50,6 +50,17 @@ export interface ComboboxProps {
50
50
  onCreate?: (text: string) => void;
51
51
  /** Texto da linha de criação. Ausente, `Criar "{text}"`. */
52
52
  createLabel?: (text: string) => string;
53
+ /**
54
+ * No modo múltiplo, fecha sozinho depois de tantos milissegundos sem
55
+ * interação — contados a partir da última marcação.
56
+ *
57
+ * O painel múltiplo fica aberto de propósito para marcar vários de uma vez,
58
+ * mas quem marcou o que queria e parou de mexer está esperando ele sumir.
59
+ * Digitar, andar com as setas, mover o mouse sobre a lista ou marcar outro
60
+ * item reinicia a contagem; abrir e só olhar não a inicia. Ausente, o
61
+ * painel só fecha no clique fora ou no Esc.
62
+ */
63
+ idleCloseMs?: number;
53
64
  }
54
65
 
55
66
  /** Id da linha de criação na lista apontável — nenhuma opção real o usa. */
@@ -93,9 +104,37 @@ export function Combobox({
93
104
  creatable = false,
94
105
  onCreate,
95
106
  createLabel,
107
+ idleCloseMs,
96
108
  }: ComboboxProps): JSX.Element {
97
109
  const { t } = useI18n();
98
110
  const [open, setOpen] = useState(false);
111
+ /* O cronômetro do fechamento por inatividade; só corre depois de uma marcação. */
112
+ const ocioso = useRef<ReturnType<typeof setTimeout> | null>(null);
113
+
114
+ const pararContagem = (): void => {
115
+ if (ocioso.current) {
116
+ clearTimeout(ocioso.current);
117
+ ocioso.current = null;
118
+ }
119
+ };
120
+
121
+ /* Reinicia a contagem — se ela já estava correndo. Quem a inicia é o `alternar`. */
122
+ const registrarInteracao = (): void => {
123
+ if (!ocioso.current) {
124
+ return;
125
+ }
126
+
127
+ pararContagem();
128
+ ocioso.current = setTimeout(() => setOpen(false), idleCloseMs);
129
+ };
130
+
131
+ useEffect(() => {
132
+ if (!open) {
133
+ pararContagem();
134
+ }
135
+
136
+ return pararContagem;
137
+ }, [open]);
99
138
  const [search, setSearch] = useState("");
100
139
  /**
101
140
  * O item que o teclado aponta. Nulo é "o primeiro que a busca deixou".
@@ -172,6 +211,11 @@ export function Combobox({
172
211
  ? value.filter((atual) => atual !== id)
173
212
  : [...value, id],
174
213
  );
214
+
215
+ if (idleCloseMs) {
216
+ pararContagem();
217
+ ocioso.current = setTimeout(() => setOpen(false), idleCloseMs);
218
+ }
175
219
  };
176
220
 
177
221
  useEffect(() => {
@@ -298,6 +342,8 @@ export function Combobox({
298
342
  * compõe os handlers com o de fora primeiro, e desiste do seu quando o
299
343
  * evento já foi tratado: o `preventDefault` daqui é o que o desarma.
300
344
  */
345
+ onPointerMove={registrarInteracao}
346
+ onKeyDownCapture={registrarInteracao}
301
347
  onKeyDown={(event) => {
302
348
  // Tecla nascida na busca já foi tratada lá.
303
349
  if (
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { JSX } from "react";
4
- import { useEffect, useState } from "react";
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
- useEffect(() => {
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
- }, [open, values, tree, mode]);
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 refreshProfile();
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
- useState,
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 [preference, setPreferenceState] =
51
- useState<ColorPreference>(DEFAULT_PREFERENCE);
52
- const [sysMode, setSysMode] = useState<Mode>("light");
53
- // Antes de ler o localStorage não se sabe o tema real. O script anti-flash do
54
- // layout já pôs a classe certa no <html>; aplicar classe antes disso só
55
- // desfaria o trabalho dele e causava piscada de claro→escuro.
56
- const [resolved, setResolved] = useState(false);
57
-
58
- useEffect(() => {
59
- const saved = safeStorage.get(STORAGE_KEY);
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
- const mq = window.matchMedia("(prefers-color-scheme: dark)");
67
- const onChange = (): void => setSysMode(systemMode());
68
- mq.addEventListener("change", onChange);
69
- return () => mq.removeEventListener("change", onChange);
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
- setPreferenceState(p);
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 [locale, setLocaleState] = useState<Locale>("pt");
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
- setLocaleState(l);
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
  }, []);