rl-core-front 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/_services/api/app-error-code.enum.ts +23 -0
- package/src/_services/api/schema.d.ts +86 -132
- package/src/_services/auth/index.ts +2 -0
- package/src/_utils/filter.ts +15 -34
- package/src/_utils/password.ts +40 -0
- package/src/_utils/permission.ts +14 -0
- package/src/_utils/storage.ts +64 -0
- package/src/_utils/user.ts +19 -0
- package/src/components/app-shell.tsx +85 -11
- package/src/components/brand-header.tsx +23 -8
- package/src/components/brand-panel.tsx +41 -0
- package/src/components/index.ts +1 -0
- package/src/components/password-field.tsx +49 -22
- package/src/components/ui/alert.tsx +34 -5
- package/src/components/ui/badge.tsx +2 -2
- package/src/components/ui/button.tsx +5 -0
- package/src/components/ui/confirm-dialog.tsx +3 -1
- package/src/components/ui/data-table.tsx +13 -4
- package/src/components/ui/dialog.tsx +120 -21
- package/src/components/ui/filter-sheet.tsx +47 -25
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/password-requirements.tsx +105 -0
- package/src/components/ui/segmented-control.tsx +75 -0
- package/src/components/ui/simple-select.tsx +74 -0
- package/src/components/ui/switch.tsx +3 -1
- package/src/components/ui/tabs.tsx +4 -8
- package/src/components/ui/track.styles.ts +31 -0
- package/src/contexts/brand-context.tsx +15 -0
- package/src/contexts/color-mode-context.tsx +4 -2
- package/src/contexts/i18n-context.tsx +72 -6
- package/src/contexts/socket-context.tsx +27 -1
- package/src/features/audit/audit-screen.tsx +13 -140
- package/src/features/audit/components/audit-diff.tsx +156 -0
- package/src/features/audit/components/index.ts +3 -0
- package/src/features/audit/components/record-audit-button.tsx +176 -0
- package/src/features/audit/enums/audit-data-action.enum.ts +20 -0
- package/src/features/audit/hooks/use-audit-trail.ts +9 -2
- package/src/features/audit/services/audit.service.ts +43 -1
- package/src/features/login/components/backup-codes-dialog.tsx +4 -4
- package/src/features/login/components/login-form.tsx +18 -3
- package/src/features/login/components/two-factor-setup.tsx +32 -2
- package/src/features/login/hooks/use-login-flow.ts +60 -7
- package/src/features/login/login-screen.tsx +99 -40
- package/src/features/logs/enums/error-type.enum.ts +21 -0
- package/src/features/logs/enums/request-outcome.enum.ts +14 -0
- package/src/features/logs/hooks/use-request-logs.ts +26 -3
- package/src/features/logs/logs-screen.tsx +2 -14
- package/src/features/logs/panels/index.ts +0 -1
- package/src/features/logs/panels/request-logs-panel.tsx +177 -25
- package/src/features/logs/services/logs.service.ts +13 -51
- package/src/features/notifications/components/notification-item.tsx +10 -8
- package/src/features/notifications/enums/notification-type.enum.ts +8 -0
- package/src/features/profile/force-password-change.tsx +18 -3
- package/src/features/profile/profile-screen.tsx +10 -2
- package/src/features/rbac/panels/groups-panel.tsx +78 -30
- package/src/features/rbac/panels/roles-panel.tsx +2 -2
- package/src/features/rbac/services/rbac.service.ts +23 -27
- package/src/features/recovery/reset-password-screen.tsx +16 -2
- package/src/features/users/components/user-form-dialog.tsx +65 -9
- package/src/features/users/hooks/use-users.ts +24 -3
- package/src/features/users/services/users.service.ts +16 -0
- package/src/features/users/users-screen.tsx +17 -12
- package/src/hooks/use-column-visibility.ts +13 -12
- package/src/hooks/use-countdown.ts +70 -0
- package/src/hooks/use-filters.ts +25 -2
- package/src/hooks/use-list-query.ts +14 -1
- package/src/hooks/use-request.ts +21 -2
- package/src/i18n/messages/en.ts +37 -6
- package/src/i18n/messages/pt.ts +41 -6
- package/src/index.ts +15 -1
- package/src/styles/core.css +99 -2
- package/src/styles/fonts/poppins-latin-400.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-500.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-600.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-700.woff2 +0 -0
- package/tailwind-preset.ts +17 -0
- package/src/components/ui/filter-chips.tsx +0 -88
- package/src/features/logs/hooks/use-error-logs.ts +0 -65
- package/src/features/logs/panels/error-logs-panel.tsx +0 -183
|
@@ -10,6 +10,7 @@ import { activeFilterCount } from "#core/_utils/filter";
|
|
|
10
10
|
import { Button } from "#core/components/ui/button";
|
|
11
11
|
import { FilterFieldControl } from "#core/components/ui/filter-field";
|
|
12
12
|
import { FilterGroupBlock } from "#core/components/ui/filter-group";
|
|
13
|
+
import { SegmentedControl } from "#core/components/ui/segmented-control";
|
|
13
14
|
import { Separator } from "#core/components/ui/separator";
|
|
14
15
|
import {
|
|
15
16
|
Sheet,
|
|
@@ -29,6 +30,17 @@ export interface FilterSheetProps {
|
|
|
29
30
|
open: boolean;
|
|
30
31
|
onOpenChange: (open: boolean) => void;
|
|
31
32
|
filters: FiltersState;
|
|
33
|
+
/**
|
|
34
|
+
* Campos que a barra da listagem já controla — somem do modo simples para a
|
|
35
|
+
* mesma escolha não existir em dois lugares.
|
|
36
|
+
*
|
|
37
|
+
* Continuam no catálogo, e isso não é detalhe: é o catálogo que serializa o
|
|
38
|
+
* filtro (`buildFilter` percorre o schema). Tirá-los de lá faria o atalho da
|
|
39
|
+
* barra parar de filtrar, sem erro nenhum. O modo avançado também segue com
|
|
40
|
+
* todos: é a saída de emergência, e esconder campo ali tornaria ilegível um
|
|
41
|
+
* filtro salvo que o use.
|
|
42
|
+
*/
|
|
43
|
+
hiddenFields?: string[];
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
/**
|
|
@@ -55,6 +67,7 @@ export function FilterSheet({
|
|
|
55
67
|
open,
|
|
56
68
|
onOpenChange,
|
|
57
69
|
filters,
|
|
70
|
+
hiddenFields = [],
|
|
58
71
|
}: FilterSheetProps): JSX.Element {
|
|
59
72
|
const { t } = useI18n();
|
|
60
73
|
const labelFor = useFieldLabel();
|
|
@@ -83,6 +96,21 @@ export function FilterSheet({
|
|
|
83
96
|
}
|
|
84
97
|
};
|
|
85
98
|
|
|
99
|
+
/**
|
|
100
|
+
* O que o painel não mostra, o painel não mexe.
|
|
101
|
+
*
|
|
102
|
+
* O desfecho de `/logs` é escolhido na barra e não aparece aqui dentro —
|
|
103
|
+
* então nem "Aplicar" nem "Limpar" podem desmarcá-lo. Filtrar por rota
|
|
104
|
+
* enquanto se olha o sucesso é somar uma condição à outra, não trocar de
|
|
105
|
+
* assunto.
|
|
106
|
+
*/
|
|
107
|
+
const keepHidden = (): FilterValues =>
|
|
108
|
+
Object.fromEntries(
|
|
109
|
+
hiddenFields
|
|
110
|
+
.filter((field) => values[field])
|
|
111
|
+
.map((field) => [field, values[field]]),
|
|
112
|
+
);
|
|
113
|
+
|
|
86
114
|
const apply = (): void => {
|
|
87
115
|
filters.setMode(draftMode);
|
|
88
116
|
if (draftMode === "advanced") {
|
|
@@ -90,18 +118,18 @@ export function FilterSheet({
|
|
|
90
118
|
filters.setTree(draftTree);
|
|
91
119
|
}
|
|
92
120
|
} else {
|
|
93
|
-
filters.setValues(draft);
|
|
121
|
+
filters.setValues({ ...draft, ...keepHidden() });
|
|
94
122
|
}
|
|
95
123
|
onOpenChange(false);
|
|
96
124
|
};
|
|
97
125
|
|
|
98
126
|
const clear = (): void => {
|
|
99
|
-
setDraft(
|
|
127
|
+
setDraft(keepHidden());
|
|
100
128
|
setDraftTree(initialTree(schema));
|
|
101
129
|
if (draftMode === "advanced") {
|
|
102
130
|
filters.setTree(initialTree(schema));
|
|
103
131
|
} else {
|
|
104
|
-
filters.setValues(
|
|
132
|
+
filters.setValues(keepHidden());
|
|
105
133
|
}
|
|
106
134
|
onOpenChange(false);
|
|
107
135
|
};
|
|
@@ -111,13 +139,17 @@ export function FilterSheet({
|
|
|
111
139
|
? draftTree
|
|
112
140
|
? countConditions(draftTree)
|
|
113
141
|
: 0
|
|
114
|
-
: activeFilterCount(draft);
|
|
142
|
+
: activeFilterCount(draft, hiddenFields);
|
|
115
143
|
|
|
116
|
-
const modes
|
|
117
|
-
{
|
|
118
|
-
{
|
|
144
|
+
const modes = [
|
|
145
|
+
{ value: "simple", label: t("filters.simple") },
|
|
146
|
+
{ value: "advanced", label: t("filters.advanced") },
|
|
119
147
|
];
|
|
120
148
|
|
|
149
|
+
const visibleFields = schema.filter(
|
|
150
|
+
(field) => !hiddenFields.includes(field.field),
|
|
151
|
+
);
|
|
152
|
+
|
|
121
153
|
return (
|
|
122
154
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
123
155
|
<SheetContent
|
|
@@ -131,23 +163,13 @@ export function FilterSheet({
|
|
|
131
163
|
</SheetDescription>
|
|
132
164
|
</SheetHeader>
|
|
133
165
|
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
"px-3 py-1.5 text-sm transition-colors",
|
|
142
|
-
draftMode === item.id
|
|
143
|
-
? "bg-primary text-primary-foreground"
|
|
144
|
-
: "hover:bg-accent",
|
|
145
|
-
)}
|
|
146
|
-
>
|
|
147
|
-
{item.label}
|
|
148
|
-
</button>
|
|
149
|
-
))}
|
|
150
|
-
</div>
|
|
166
|
+
<SegmentedControl
|
|
167
|
+
fullWidth
|
|
168
|
+
label={t("filters.title")}
|
|
169
|
+
options={modes}
|
|
170
|
+
value={draftMode}
|
|
171
|
+
onValueChange={(value) => switchMode(value as FilterMode)}
|
|
172
|
+
/>
|
|
151
173
|
|
|
152
174
|
<SheetBody className="-mx-1 space-y-5 px-1">
|
|
153
175
|
{loading && <Spinner className="mx-auto mt-8" />}
|
|
@@ -160,7 +182,7 @@ export function FilterSheet({
|
|
|
160
182
|
|
|
161
183
|
{!loading && schema.length > 0 && draftMode === "simple" && (
|
|
162
184
|
<>
|
|
163
|
-
{
|
|
185
|
+
{visibleFields.map((field) => (
|
|
164
186
|
<FilterFieldControl
|
|
165
187
|
key={field.field}
|
|
166
188
|
field={field}
|
|
@@ -12,17 +12,19 @@ export * from "./date-range-picker";
|
|
|
12
12
|
export * from "./dialog";
|
|
13
13
|
export * from "./dropdown-menu";
|
|
14
14
|
export * from "./field";
|
|
15
|
-
export * from "./filter-chips";
|
|
16
15
|
export * from "./filter-field";
|
|
17
16
|
export * from "./filter-group";
|
|
18
17
|
export * from "./filter-rule";
|
|
19
18
|
export * from "./filter-sheet";
|
|
20
19
|
export * from "./input";
|
|
21
20
|
export * from "./label";
|
|
21
|
+
export * from "./password-requirements";
|
|
22
22
|
export * from "./row-actions";
|
|
23
|
+
export * from "./segmented-control";
|
|
23
24
|
export * from "./select";
|
|
24
25
|
export * from "./separator";
|
|
25
26
|
export * from "./sheet";
|
|
27
|
+
export * from "./simple-select";
|
|
26
28
|
export * from "./sonner";
|
|
27
29
|
export * from "./spinner";
|
|
28
30
|
export * from "./switch";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, Circle } from "lucide-react";
|
|
4
|
+
import type { JSX } from "react";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
PASSWORD_RULES,
|
|
8
|
+
passwordScore,
|
|
9
|
+
} from "#core/_utils/password";
|
|
10
|
+
import { useI18n } from "#core/contexts";
|
|
11
|
+
import { cn } from "#core/lib/utils";
|
|
12
|
+
|
|
13
|
+
export interface PasswordRequirementsProps {
|
|
14
|
+
/** Valor atual do campo de senha. */
|
|
15
|
+
value: string;
|
|
16
|
+
/** Esconde a barra de força e deixa só a lista. */
|
|
17
|
+
hideStrength?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Cor da barra por faixa de força — cheia, porque aqui é preenchimento. */
|
|
22
|
+
const STRENGTH_COLOR = (score: number, total: number): string => {
|
|
23
|
+
if (score === total) {
|
|
24
|
+
return "bg-success";
|
|
25
|
+
}
|
|
26
|
+
if (score >= total - 1) {
|
|
27
|
+
return "bg-warning";
|
|
28
|
+
}
|
|
29
|
+
return "bg-destructive";
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lista viva das exigências de senha, marcando o que já foi cumprido.
|
|
34
|
+
*
|
|
35
|
+
* Substitui a mensagem única de erro ("mín. 8 caracteres com maiúscula,
|
|
36
|
+
* minúscula, número e símbolo"), que só aparecia depois de errar e obrigava a
|
|
37
|
+
* pessoa a comparar a frase inteira com o que digitou para achar o que faltava.
|
|
38
|
+
*
|
|
39
|
+
* Não valida nada: quem decide é o schema Yup da tela, com o mesmo regex. Isto
|
|
40
|
+
* é a explicação em forma de lista — por isso mora em `components/ui` e não
|
|
41
|
+
* conhece formulário nenhum.
|
|
42
|
+
*/
|
|
43
|
+
export function PasswordRequirements({
|
|
44
|
+
value,
|
|
45
|
+
hideStrength = false,
|
|
46
|
+
className,
|
|
47
|
+
}: PasswordRequirementsProps): JSX.Element {
|
|
48
|
+
const { t } = useI18n();
|
|
49
|
+
const total = PASSWORD_RULES.length;
|
|
50
|
+
const score = passwordScore(value);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className={cn("flex flex-col gap-2", className)}>
|
|
54
|
+
{!hideStrength && (
|
|
55
|
+
<div
|
|
56
|
+
className="h-1.5 w-full overflow-hidden rounded-full bg-track"
|
|
57
|
+
role="progressbar"
|
|
58
|
+
aria-valuenow={score}
|
|
59
|
+
aria-valuemin={0}
|
|
60
|
+
aria-valuemax={total}
|
|
61
|
+
aria-label={t("validation.rules.strength")}
|
|
62
|
+
>
|
|
63
|
+
<div
|
|
64
|
+
className={cn(
|
|
65
|
+
"h-full rounded-full transition-all duration-300",
|
|
66
|
+
STRENGTH_COLOR(score, total),
|
|
67
|
+
)}
|
|
68
|
+
style={{ width: `${(score / total) * 100}%` }}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{/*
|
|
74
|
+
`list-none` com ícone próprio, e não marcador nativo: o ✓/○ é o que
|
|
75
|
+
carrega o estado. `aria-live` para quem usa leitor de tela perceber a
|
|
76
|
+
regra sendo cumprida enquanto digita.
|
|
77
|
+
*/}
|
|
78
|
+
<ul className="flex flex-col gap-1" aria-live="polite">
|
|
79
|
+
{PASSWORD_RULES.map((rule) => {
|
|
80
|
+
const ok = rule.test(value);
|
|
81
|
+
return (
|
|
82
|
+
<li
|
|
83
|
+
key={rule.key}
|
|
84
|
+
className={cn(
|
|
85
|
+
"flex items-start gap-2 text-xs transition-colors",
|
|
86
|
+
ok ? "text-success-strong" : "text-muted-foreground",
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
<span className="mt-0.5 shrink-0" aria-hidden>
|
|
90
|
+
{ok ? (
|
|
91
|
+
<Check className="h-3.5 w-3.5" />
|
|
92
|
+
) : (
|
|
93
|
+
<Circle className="h-3.5 w-3.5" />
|
|
94
|
+
)}
|
|
95
|
+
</span>
|
|
96
|
+
{/* Sem truncamento: a regra tem que caber inteira, quebrando linha
|
|
97
|
+
se precisar — cortada, ela deixa de dizer o que exige. */}
|
|
98
|
+
<span className="min-w-0 flex-1">{t(rule.key, rule.params)}</span>
|
|
99
|
+
</li>
|
|
100
|
+
);
|
|
101
|
+
})}
|
|
102
|
+
</ul>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "#core/lib/utils";
|
|
6
|
+
|
|
7
|
+
import { TRACK_CLASS, TRACK_ITEM_CLASS } from "./track.styles";
|
|
8
|
+
|
|
9
|
+
export interface SegmentedOption {
|
|
10
|
+
value: string;
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SegmentedControlProps
|
|
15
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
16
|
+
options: SegmentedOption[];
|
|
17
|
+
value: string;
|
|
18
|
+
onValueChange: (value: string) => void;
|
|
19
|
+
/** Rótulo acessível do grupo — o que essas opções escolhem. */
|
|
20
|
+
label: string;
|
|
21
|
+
/**
|
|
22
|
+
* Ocupa a largura toda, com as opções dividindo o espaço em partes iguais.
|
|
23
|
+
*
|
|
24
|
+
* Para quando o controle é o cabeçalho de um bloco — duas opções encostadas à
|
|
25
|
+
* esquerda de um painel largo ficam tortas. Solto, ele mede pelo conteúdo.
|
|
26
|
+
*/
|
|
27
|
+
fullWidth?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Escolha única no formato das abas: uma opção acesa dentro de uma trilha.
|
|
32
|
+
*
|
|
33
|
+
* Tem a pele do `Tabs`, mas não é aba: aba troca de painel, isto escolhe um
|
|
34
|
+
* valor. Por isso são botões de alternância num `role="group"`, e não o
|
|
35
|
+
* `Tabs` do Radix — que emitiria `role="tablist"` com `aria-controls`
|
|
36
|
+
* apontando para um painel inexistente.
|
|
37
|
+
*
|
|
38
|
+
* Vale quando as opções cabem na linha e são poucas (até ~5). Passando disso,
|
|
39
|
+
* `Select` — trilha que quebra em duas linhas fica pior que uma lista.
|
|
40
|
+
*/
|
|
41
|
+
export const SegmentedControl = ({
|
|
42
|
+
options,
|
|
43
|
+
value,
|
|
44
|
+
onValueChange,
|
|
45
|
+
label,
|
|
46
|
+
fullWidth = false,
|
|
47
|
+
className,
|
|
48
|
+
...props
|
|
49
|
+
}: SegmentedControlProps): React.JSX.Element => (
|
|
50
|
+
<div
|
|
51
|
+
role="group"
|
|
52
|
+
aria-label={label}
|
|
53
|
+
className={cn(
|
|
54
|
+
TRACK_CLASS,
|
|
55
|
+
fullWidth ? "flex w-full" : "inline-flex",
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
{...props}
|
|
59
|
+
>
|
|
60
|
+
{options.map((option) => (
|
|
61
|
+
<button
|
|
62
|
+
key={option.value}
|
|
63
|
+
type="button"
|
|
64
|
+
aria-pressed={option.value === value}
|
|
65
|
+
// Só para vestir a pele compartilhada, que seleciona por `data-state`.
|
|
66
|
+
// Quem anuncia o estado ao leitor de tela continua sendo o aria-pressed.
|
|
67
|
+
data-state={option.value === value ? "active" : "inactive"}
|
|
68
|
+
onClick={() => onValueChange(option.value)}
|
|
69
|
+
className={cn(TRACK_ITEM_CLASS, fullWidth && "flex-1")}
|
|
70
|
+
>
|
|
71
|
+
{option.label}
|
|
72
|
+
</button>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from "#core/components/ui/select";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mesmo formato que o `Label` dos enums do §7 já produz — nada a converter
|
|
15
|
+
* entre um catálogo de opções e este componente.
|
|
16
|
+
*/
|
|
17
|
+
export interface SelectOption {
|
|
18
|
+
value: string;
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SimpleSelectProps {
|
|
23
|
+
value: string;
|
|
24
|
+
onChange: (value: string) => void;
|
|
25
|
+
options: SelectOption[];
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Rótulo da opção "nenhum". Ausente, o campo não oferece vazio.
|
|
29
|
+
*
|
|
30
|
+
* O Radix não aceita `value=""` num item, então a opção vazia precisa de uma
|
|
31
|
+
* chave-sentinela — detalhe que some aqui dentro em vez de reaparecer em toda
|
|
32
|
+
* tela que precisa de um campo opcional.
|
|
33
|
+
*/
|
|
34
|
+
emptyLabel?: string;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const EMPTY = "__none__";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* O caso comum do `Select`: uma lista de opções e um valor.
|
|
42
|
+
*
|
|
43
|
+
* Os primitivos do `select.tsx` continuam disponíveis para quem precisa de
|
|
44
|
+
* grupos, separadores ou item com markup — este cobre o resto, que era a
|
|
45
|
+
* mesma casca de cinco componentes repetida a cada campo.
|
|
46
|
+
*/
|
|
47
|
+
export function SimpleSelect({
|
|
48
|
+
value,
|
|
49
|
+
onChange,
|
|
50
|
+
options,
|
|
51
|
+
placeholder,
|
|
52
|
+
emptyLabel,
|
|
53
|
+
disabled,
|
|
54
|
+
}: SimpleSelectProps): JSX.Element {
|
|
55
|
+
return (
|
|
56
|
+
<Select
|
|
57
|
+
value={value || (emptyLabel ? EMPTY : "")}
|
|
58
|
+
onValueChange={(next) => onChange(next === EMPTY ? "" : next)}
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
>
|
|
61
|
+
<SelectTrigger>
|
|
62
|
+
<SelectValue placeholder={placeholder} />
|
|
63
|
+
</SelectTrigger>
|
|
64
|
+
<SelectContent>
|
|
65
|
+
{emptyLabel && <SelectItem value={EMPTY}>{emptyLabel}</SelectItem>}
|
|
66
|
+
{options.map((option) => (
|
|
67
|
+
<SelectItem key={option.value} value={option.value}>
|
|
68
|
+
{option.label}
|
|
69
|
+
</SelectItem>
|
|
70
|
+
))}
|
|
71
|
+
</SelectContent>
|
|
72
|
+
</Select>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -19,7 +19,9 @@ const Switch = React.forwardRef<
|
|
|
19
19
|
>
|
|
20
20
|
<SwitchPrimitives.Thumb
|
|
21
21
|
className={cn(
|
|
22
|
-
|
|
22
|
+
// `bg-card` e não `bg-background`: no tema claro o card é branco puro e
|
|
23
|
+
// destaca do trilho; o fundo da página quase se confunde com ele.
|
|
24
|
+
"pointer-events-none block h-4 w-4 rounded-full bg-card shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
|
23
25
|
)}
|
|
24
26
|
/>
|
|
25
27
|
</SwitchPrimitives.Root>
|
|
@@ -5,6 +5,8 @@ import * as React from "react";
|
|
|
5
5
|
|
|
6
6
|
import { cn } from "#core/lib/utils";
|
|
7
7
|
|
|
8
|
+
import { TRACK_CLASS, TRACK_ITEM_CLASS } from "./track.styles";
|
|
9
|
+
|
|
8
10
|
const Tabs = TabsPrimitive.Root;
|
|
9
11
|
|
|
10
12
|
const TabsList = React.forwardRef<
|
|
@@ -13,10 +15,7 @@ const TabsList = React.forwardRef<
|
|
|
13
15
|
>(({ className, ...props }, ref) => (
|
|
14
16
|
<TabsPrimitive.List
|
|
15
17
|
ref={ref}
|
|
16
|
-
className={cn(
|
|
17
|
-
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
|
18
|
-
className,
|
|
19
|
-
)}
|
|
18
|
+
className={cn("inline-flex", TRACK_CLASS, className)}
|
|
20
19
|
{...props}
|
|
21
20
|
/>
|
|
22
21
|
));
|
|
@@ -28,10 +27,7 @@ const TabsTrigger = React.forwardRef<
|
|
|
28
27
|
>(({ className, ...props }, ref) => (
|
|
29
28
|
<TabsPrimitive.Trigger
|
|
30
29
|
ref={ref}
|
|
31
|
-
className={cn(
|
|
32
|
-
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
|
33
|
-
className,
|
|
34
|
-
)}
|
|
30
|
+
className={cn(TRACK_ITEM_CLASS, className)}
|
|
35
31
|
{...props}
|
|
36
32
|
/>
|
|
37
33
|
));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pele compartilhada dos controles em trilha: `Tabs` e `SegmentedControl`.
|
|
3
|
+
*
|
|
4
|
+
* Os dois sempre tiveram a mesma aparência, mas cada um com a sua cópia das
|
|
5
|
+
* classes — então o contraste quebrado no tema escuro precisava ser corrigido
|
|
6
|
+
* em dois lugares, e só um foi. Aqui é uma verdade só (§6 do CLAUDE.md).
|
|
7
|
+
*
|
|
8
|
+
* Ambos marcam o item com `data-state="active" | "inactive"`: o `Tabs` porque o
|
|
9
|
+
* Radix já emite esse atributo, o `SegmentedControl` porque escrevê-lo à mão
|
|
10
|
+
* sai mais barato que manter uma segunda lista de classes condicionais.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A trilha: o fundo em que as opções vivem. */
|
|
14
|
+
export const TRACK_CLASS =
|
|
15
|
+
"h-10 items-center justify-center rounded-lg bg-track p-1 text-muted-foreground ring-1 ring-inset ring-border/60";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A opção. O estado aceso vem por `data-[state=active]`, com anel próprio:
|
|
19
|
+
* a sombra sozinha é invisível sobre fundo escuro, e era parte do motivo de a
|
|
20
|
+
* opção selecionada sumir no tema escuro.
|
|
21
|
+
*/
|
|
22
|
+
export const TRACK_ITEM_CLASS = [
|
|
23
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5",
|
|
24
|
+
"text-sm font-medium ring-offset-background transition-all",
|
|
25
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
26
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
27
|
+
// Feedback de mouse no que ainda não está selecionado — antes não havia nenhum.
|
|
28
|
+
"data-[state=inactive]:hover:bg-foreground/5 data-[state=inactive]:hover:text-foreground",
|
|
29
|
+
"data-[state=active]:bg-track-active data-[state=active]:text-foreground",
|
|
30
|
+
"data-[state=active]:shadow-sm data-[state=active]:ring-1 data-[state=active]:ring-border",
|
|
31
|
+
].join(" ");
|
|
@@ -33,6 +33,18 @@ export interface Brand {
|
|
|
33
33
|
logo?: ReactNode;
|
|
34
34
|
/** Frase curta sob a logo na tela de login. Opcional. */
|
|
35
35
|
tagline?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Onde a marca fica na tela de login.
|
|
38
|
+
*
|
|
39
|
+
* - `"top"` (padrão): logo pequena acima do formulário, tudo num cartão só.
|
|
40
|
+
* - `"right"` / `"left"`: a marca ocupa metade da tela, com um divisor entre
|
|
41
|
+
* ela e o formulário. É para logo grande — espremida no topo do cartão ela
|
|
42
|
+
* sai ilegível, e aumentar o cartão para caber estragaria o formulário.
|
|
43
|
+
*
|
|
44
|
+
* Em tela estreita os dois lados viram empilhado com a marca em cima: meia
|
|
45
|
+
* tela de logo num celular não deixaria o formulário aparecer.
|
|
46
|
+
*/
|
|
47
|
+
loginLayout?: "top" | "left" | "right";
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
/** O que os componentes do core consomem: sem opcionais, tudo já resolvido. */
|
|
@@ -41,6 +53,7 @@ export interface BrandContextType {
|
|
|
41
53
|
mark: ReactNode;
|
|
42
54
|
logo: ReactNode;
|
|
43
55
|
tagline: string | null;
|
|
56
|
+
loginLayout: "top" | "left" | "right";
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
const BrandContext = createContext<BrandContextType | undefined>(undefined);
|
|
@@ -64,6 +77,7 @@ export function BrandProvider({
|
|
|
64
77
|
mark,
|
|
65
78
|
logo: brand?.logo ?? mark,
|
|
66
79
|
tagline: brand?.tagline ?? null,
|
|
80
|
+
loginLayout: brand?.loginLayout ?? "top",
|
|
67
81
|
};
|
|
68
82
|
}, [brand, t]);
|
|
69
83
|
|
|
@@ -88,6 +102,7 @@ export function useBrand(): BrandContextType {
|
|
|
88
102
|
mark: defaultMark,
|
|
89
103
|
logo: defaultMark,
|
|
90
104
|
tagline: null,
|
|
105
|
+
loginLayout: "top",
|
|
91
106
|
}
|
|
92
107
|
);
|
|
93
108
|
}
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
useState,
|
|
11
11
|
} from "react";
|
|
12
12
|
|
|
13
|
+
import { safeStorage } from "#core/_utils/storage";
|
|
14
|
+
|
|
13
15
|
export const STORAGE_KEY = "color-mode";
|
|
14
16
|
|
|
15
17
|
export type ColorPreference = "light" | "dark" | "system";
|
|
@@ -54,7 +56,7 @@ export function ColorModeProvider({ children }: { children: React.ReactNode }):
|
|
|
54
56
|
const [resolved, setResolved] = useState(false);
|
|
55
57
|
|
|
56
58
|
useEffect(() => {
|
|
57
|
-
const saved =
|
|
59
|
+
const saved = safeStorage.get(STORAGE_KEY);
|
|
58
60
|
if (isPreference(saved)) {
|
|
59
61
|
setPreferenceState(saved);
|
|
60
62
|
}
|
|
@@ -77,7 +79,7 @@ export function ColorModeProvider({ children }: { children: React.ReactNode }):
|
|
|
77
79
|
|
|
78
80
|
const setPreference = useCallback((p: ColorPreference) => {
|
|
79
81
|
setPreferenceState(p);
|
|
80
|
-
|
|
82
|
+
safeStorage.set(STORAGE_KEY, p);
|
|
81
83
|
}, []);
|
|
82
84
|
|
|
83
85
|
const toggle = useCallback(
|
|
@@ -10,13 +10,50 @@ import {
|
|
|
10
10
|
useState,
|
|
11
11
|
} from "react";
|
|
12
12
|
|
|
13
|
+
import { safeStorage } from "#core/_utils/storage";
|
|
13
14
|
import { en } from "#core/i18n/messages/en";
|
|
14
15
|
import { pt } from "#core/i18n/messages/pt";
|
|
15
16
|
|
|
16
17
|
export type Locale = "pt" | "en";
|
|
17
|
-
const MESSAGES = { pt, en } as const;
|
|
18
18
|
const STORAGE_KEY = "locale";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Dicionário de um idioma: objeto aninhado de strings, como `i18n/messages`.
|
|
22
|
+
*
|
|
23
|
+
* O tipo é aberto de propósito — o projeto acrescenta as seções dele, e travar
|
|
24
|
+
* a forma aqui obrigaria o core a conhecer as chaves de quem o instala.
|
|
25
|
+
*/
|
|
26
|
+
export type Messages = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
/** Dicionário do projeto, por idioma. Cada um é opcional. */
|
|
29
|
+
export type ProjectMessages = Partial<Record<Locale, Messages>>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mescla o dicionário do projeto sobre o do core.
|
|
33
|
+
*
|
|
34
|
+
* É profundo porque as seções coincidem: um projeto que acrescenta
|
|
35
|
+
* `nav.tasks` não pode apagar o resto de `nav`, que é o que o shell usa. Em
|
|
36
|
+
* conflito, o projeto vence — é o que permite renomear "Logs" sem tocar no
|
|
37
|
+
* pacote.
|
|
38
|
+
*/
|
|
39
|
+
function deepMerge(base: Messages, override: Messages): Messages {
|
|
40
|
+
const result: Messages = { ...base };
|
|
41
|
+
for (const [key, value] of Object.entries(override)) {
|
|
42
|
+
const current = result[key];
|
|
43
|
+
const bothObjects =
|
|
44
|
+
current !== null &&
|
|
45
|
+
typeof current === "object" &&
|
|
46
|
+
!Array.isArray(current) &&
|
|
47
|
+
value !== null &&
|
|
48
|
+
typeof value === "object" &&
|
|
49
|
+
!Array.isArray(value);
|
|
50
|
+
result[key] = bothObjects
|
|
51
|
+
? deepMerge(current as Messages, value as Messages)
|
|
52
|
+
: value;
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
20
57
|
export interface I18nContextType {
|
|
21
58
|
locale: Locale;
|
|
22
59
|
setLocale: (l: Locale) => void;
|
|
@@ -34,31 +71,60 @@ function resolve(obj: unknown, path: string): string | undefined {
|
|
|
34
71
|
}, obj) as string | undefined;
|
|
35
72
|
}
|
|
36
73
|
|
|
37
|
-
export
|
|
74
|
+
export interface I18nProviderProps {
|
|
75
|
+
children: React.ReactNode;
|
|
76
|
+
/**
|
|
77
|
+
* Dicionário do projeto, mesclado sobre o do core (§14: o core não sabe os
|
|
78
|
+
* textos de quem o instala).
|
|
79
|
+
*
|
|
80
|
+
* ```tsx
|
|
81
|
+
* <I18nProvider messages={{ pt: { nav: { tasks: "Tarefas" } } }}>
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
messages?: ProjectMessages;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function I18nProvider({
|
|
88
|
+
children,
|
|
89
|
+
messages,
|
|
90
|
+
}: I18nProviderProps): JSX.Element {
|
|
38
91
|
const [locale, setLocaleState] = useState<Locale>("pt");
|
|
39
92
|
|
|
93
|
+
// Mescla uma vez por dicionário, não a cada tradução: `t` roda em todo render
|
|
94
|
+
// de toda tela que usa texto.
|
|
95
|
+
const dictionary = useMemo(
|
|
96
|
+
() => ({
|
|
97
|
+
pt: messages?.pt ? deepMerge(pt, messages.pt) : (pt as Messages),
|
|
98
|
+
en: messages?.en ? deepMerge(en, messages.en) : (en as Messages),
|
|
99
|
+
}),
|
|
100
|
+
[messages],
|
|
101
|
+
);
|
|
102
|
+
|
|
40
103
|
useEffect(() => {
|
|
41
|
-
const saved = (
|
|
104
|
+
const saved = (safeStorage.get(STORAGE_KEY) as Locale) || null;
|
|
42
105
|
if (saved === "pt" || saved === "en") {setLocaleState(saved);}
|
|
43
106
|
}, []);
|
|
44
107
|
|
|
45
108
|
const setLocale = useCallback((l: Locale) => {
|
|
46
109
|
setLocaleState(l);
|
|
47
|
-
|
|
110
|
+
safeStorage.set(STORAGE_KEY, l);
|
|
48
111
|
document.cookie = `${STORAGE_KEY}=${l}; path=/; max-age=31536000`;
|
|
49
112
|
}, []);
|
|
50
113
|
|
|
51
114
|
const t = useCallback(
|
|
52
115
|
(key: string, params?: Record<string, string | number>) => {
|
|
116
|
+
// Sem tradução no idioma corrente, cai no português; sem nenhuma, devolve
|
|
117
|
+
// a própria chave — é o que deixa `label: "Tarefas"` funcionar como texto
|
|
118
|
+
// cru em quem não quer cadastrar dicionário.
|
|
53
119
|
const raw =
|
|
54
|
-
resolve(
|
|
120
|
+
resolve(dictionary[locale], key) ?? resolve(dictionary.pt, key) ?? key;
|
|
55
121
|
if (!params) {return raw;}
|
|
56
122
|
return Object.entries(params).reduce(
|
|
57
123
|
(str, [k, v]) => str.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)),
|
|
58
124
|
raw,
|
|
59
125
|
);
|
|
60
126
|
},
|
|
61
|
-
[locale],
|
|
127
|
+
[locale, dictionary],
|
|
62
128
|
);
|
|
63
129
|
|
|
64
130
|
const value = useMemo(
|