rl-core-front 0.18.6 → 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.
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/data-table.tsx +49 -9
  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.6",
3
+ "version": "0.18.8",
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;
@@ -34,6 +34,7 @@ import {
34
34
  import type {
35
35
  CSSProperties,
36
36
  JSX,
37
+ MouseEvent as ReactMouseEvent,
37
38
  PointerEvent as ReactPointerEvent,
38
39
  } from "react";
39
40
  import { Fragment, useEffect, useMemo, useRef, useState } from "react";
@@ -468,9 +469,40 @@ const CELL_MAX_WIDTH = "24rem";
468
469
  * cortaria botão de ação. A que declara `wrap` também: nela a quebra é o
469
470
  * desenho.
470
471
  */
471
- function cellStyle(columnDef: {
472
- meta?: unknown;
473
- }): { className?: string; style?: CSSProperties } {
472
+ /** A célula (ou o que ela tem dentro) não coube e está cortada. */
473
+ function isClipped(cell: HTMLElement): boolean {
474
+ if (cell.scrollWidth > cell.clientWidth) {
475
+ return true;
476
+ }
477
+
478
+ return Array.from(cell.children).some(
479
+ (child) => child.scrollWidth > child.clientWidth,
480
+ );
481
+ }
482
+
483
+ /**
484
+ * Abre a célula sob o cursor — se houver o que abrir.
485
+ *
486
+ * A medição acontece aqui, e não em cada render: enquanto ninguém passa o
487
+ * mouse, isto não custa nada. E mexer na `dataset` em vez de subir estado
488
+ * evita re-renderizar a tabela inteira a cada célula que o ponteiro cruza.
489
+ */
490
+ function expandIfClipped(event: ReactMouseEvent<HTMLTableCellElement>): void {
491
+ if (isClipped(event.currentTarget)) {
492
+ event.currentTarget.dataset.expand = "true";
493
+ }
494
+ }
495
+
496
+ function collapse(event: ReactMouseEvent<HTMLTableCellElement>): void {
497
+ delete event.currentTarget.dataset.expand;
498
+ }
499
+
500
+ function cellStyle(columnDef: { meta?: unknown }): {
501
+ className?: string;
502
+ style?: CSSProperties;
503
+ onMouseEnter?: (event: ReactMouseEvent<HTMLTableCellElement>) => void;
504
+ onMouseLeave?: (event: ReactMouseEvent<HTMLTableCellElement>) => void;
505
+ } {
474
506
  const meta = columnDef.meta as DataTableColumnMeta | undefined;
475
507
 
476
508
  if (meta?.shrink || meta?.wrap) {
@@ -487,16 +519,24 @@ function cellStyle(columnDef: {
487
519
  aparecem nos dois casos.
488
520
  */
489
521
  /*
490
- E o mouse em cima devolve o texto inteiro: no hovero `white-space`
491
- muda, a frase volta a quebrar e a célula cresce em altura. É o mesmo gesto
492
- das descrições do Financeiro — sem `title`, que só aparece depois de um
493
- segundo parado, e sem tooltip flutuante, que sairia da tabela e taparia a
494
- linha de baixo.
522
+ E o mouse em cima devolve o texto inteiro mas **na célula que está de
523
+ fato cortada**.
524
+
525
+ Com `hover:` puro, passar o mouse por uma célula que cabia inteira a
526
+ fazia quebrar em duas linhas do mesmo jeito, e a linha inteira pulava sob o
527
+ cursor. CSS não sabe se houve corte; quem sabe é o layout já calculado, daí
528
+ a medição no `mouseenter` — que não custa render nenhum enquanto ninguém
529
+ passa o mouse. O `data-expand` liga as duas regras abaixo.
530
+
531
+ Sem `title`, que só aparece depois de um segundo parado, e sem tooltip
532
+ flutuante, que sairia da tabela e taparia a linha de baixo.
495
533
  */
496
534
  return {
497
535
  className:
498
- "truncate hover:whitespace-normal [&>*]:truncate hover:[&>*]:whitespace-normal",
536
+ "truncate [&>*]:truncate data-[expand=true]:whitespace-normal data-[expand=true]:[&>*]:whitespace-normal",
499
537
  style: { maxWidth: meta?.maxWidth ?? CELL_MAX_WIDTH },
538
+ onMouseEnter: expandIfClipped,
539
+ onMouseLeave: collapse,
500
540
  };
501
541
  }
502
542
 
@@ -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
  }, []);