rl-core-front 0.15.6 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/_services/api/schema.d.ts +267 -4
- package/src/_services/images/index.ts +81 -0
- package/src/_utils/upload-url.ts +27 -0
- package/src/components/index.ts +1 -0
- package/src/components/job-dock.tsx +118 -0
- package/src/components/ui/data-table.tsx +113 -2
- package/src/components/ui/image-upload.tsx +329 -0
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/tab-shortcuts.tsx +77 -0
- package/src/components/user-avatar.tsx +7 -13
- package/src/contexts/index.ts +2 -0
- package/src/contexts/jobs-context.tsx +146 -0
- package/src/contexts/jobs-store.ts +391 -0
- package/src/features/login/hooks/use-login-flow.ts +1 -1
- package/src/features/notifications/components/notification-item.tsx +34 -10
- package/src/features/notifications/hooks/use-notifications.ts +32 -1
- package/src/features/notifications/notifications-center.tsx +37 -15
- package/src/features/notifications/services/notifications.service.ts +10 -0
- package/src/features/profile/force-password-change.tsx +1 -1
- package/src/features/profile/profile-screen.tsx +4 -2
- package/src/features/recovery/reset-password-screen.tsx +1 -1
- package/src/features/shortcuts/components/shortcut-card.tsx +0 -5
- package/src/features/shortcuts/shortcuts-screen.tsx +14 -3
- package/src/hooks/use-image-upload.ts +169 -0
- package/src/hooks/use-request.ts +47 -5
- package/src/hooks/use-shortcut.ts +52 -3
- package/src/i18n/messages/en.ts +27 -1
- package/src/i18n/messages/pt.ts +27 -1
- package/src/index.ts +32 -0
- package/src/styles/core.css +31 -0
|
@@ -18,7 +18,7 @@ import { useNotifications } from "#core/features/notifications/hooks/use-notific
|
|
|
18
18
|
import type { UserNotification } from "#core/features/notifications/services/notifications.service";
|
|
19
19
|
import { cn } from "#core/lib/utils";
|
|
20
20
|
|
|
21
|
-
type Tab = "all" | "unread";
|
|
21
|
+
type Tab = "all" | "unread" | "read";
|
|
22
22
|
|
|
23
23
|
/** Acima disso o número não cabe no badge sem empurrar o layout. */
|
|
24
24
|
const BADGE_LIMIT = 99;
|
|
@@ -26,11 +26,18 @@ const BADGE_LIMIT = 99;
|
|
|
26
26
|
export function NotificationsCenter(): JSX.Element {
|
|
27
27
|
const { t } = useI18n();
|
|
28
28
|
const router = useRouter();
|
|
29
|
-
const { items, unread, loading, markAsRead, markAllAsRead } =
|
|
29
|
+
const { items, unread, loading, markAsRead, markAllAsRead, dismiss, dismissRead } =
|
|
30
30
|
useNotifications();
|
|
31
31
|
const [tab, setTab] = useState<Tab>("all");
|
|
32
32
|
|
|
33
|
-
const visible =
|
|
33
|
+
const visible =
|
|
34
|
+
tab === "unread"
|
|
35
|
+
? items.filter((i) => !i.read)
|
|
36
|
+
: tab === "read"
|
|
37
|
+
? items.filter((i) => i.read)
|
|
38
|
+
: items;
|
|
39
|
+
|
|
40
|
+
const lidas = items.filter((i) => i.read).length;
|
|
34
41
|
|
|
35
42
|
const onSelect = (notification: UserNotification): void => {
|
|
36
43
|
if (!notification.read) {
|
|
@@ -63,7 +70,7 @@ export function NotificationsCenter(): JSX.Element {
|
|
|
63
70
|
<DropdownMenuContent align="end" className="w-96 p-0">
|
|
64
71
|
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
|
65
72
|
<div className="flex gap-3">
|
|
66
|
-
{(["all", "unread"] as const).map((value) => (
|
|
73
|
+
{(["all", "unread", "read"] as const).map((value) => (
|
|
67
74
|
<button
|
|
68
75
|
key={value}
|
|
69
76
|
type="button"
|
|
@@ -75,21 +82,35 @@ export function NotificationsCenter(): JSX.Element {
|
|
|
75
82
|
: "border-transparent text-muted-foreground hover:text-foreground",
|
|
76
83
|
)}
|
|
77
84
|
>
|
|
78
|
-
{value
|
|
79
|
-
? t("notifications.all")
|
|
80
|
-
: t("notifications.unread")}
|
|
85
|
+
{t(`notifications.${value}`)}
|
|
81
86
|
</button>
|
|
82
87
|
))}
|
|
83
88
|
</div>
|
|
84
89
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
{/*
|
|
91
|
+
A ação do cabeçalho segue a aba. Em "Lidas" o que se quer é
|
|
92
|
+
esvaziar, e "marcar todas como lidas" ali não faria nada — botão que
|
|
93
|
+
não pode fazer efeito é botão que ensina a pessoa a desconfiar.
|
|
94
|
+
*/}
|
|
95
|
+
{tab === "read" ? (
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={() => void dismissRead()}
|
|
99
|
+
disabled={lidas === 0}
|
|
100
|
+
className="text-xs font-medium text-primary hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline"
|
|
101
|
+
>
|
|
102
|
+
{t("notifications.clearRead")}
|
|
103
|
+
</button>
|
|
104
|
+
) : (
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => void markAllAsRead()}
|
|
108
|
+
disabled={unread === 0}
|
|
109
|
+
className="text-xs font-medium text-primary hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline"
|
|
110
|
+
>
|
|
111
|
+
{t("notifications.markAllRead")}
|
|
112
|
+
</button>
|
|
113
|
+
)}
|
|
93
114
|
</div>
|
|
94
115
|
|
|
95
116
|
<div className="max-h-96 overflow-y-auto">
|
|
@@ -110,6 +131,7 @@ export function NotificationsCenter(): JSX.Element {
|
|
|
110
131
|
key={notification.id}
|
|
111
132
|
notification={notification}
|
|
112
133
|
onSelect={onSelect}
|
|
134
|
+
onDismiss={(id) => void dismiss(id)}
|
|
113
135
|
/>
|
|
114
136
|
))}
|
|
115
137
|
</div>
|
|
@@ -35,4 +35,14 @@ export const notificationsService = {
|
|
|
35
35
|
markAllAsRead() {
|
|
36
36
|
return api.patch("/notifications/read-all", {}).then((r) => r.data);
|
|
37
37
|
},
|
|
38
|
+
|
|
39
|
+
/** Tira uma da caixa de quem chamou — não apaga para os outros. */
|
|
40
|
+
dismiss(id: string) {
|
|
41
|
+
return api.delete(`/notifications/${id}`).then(() => undefined);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/** Limpa as já lidas. Só elas: o não lido é o que não pode se perder. */
|
|
45
|
+
dismissRead() {
|
|
46
|
+
return api.delete("/notifications/read").then(() => undefined);
|
|
47
|
+
},
|
|
38
48
|
};
|
|
@@ -22,7 +22,7 @@ export function ForcePasswordChange({
|
|
|
22
22
|
}: {
|
|
23
23
|
reason?: "expired" | "provisional";
|
|
24
24
|
}): JSX.Element {
|
|
25
|
-
const { run, loading, error } = useRequest();
|
|
25
|
+
const { run, loading, error } = useRequest({ silent: true });
|
|
26
26
|
const { logout } = useAuth();
|
|
27
27
|
const { t } = useI18n();
|
|
28
28
|
|
|
@@ -53,10 +53,12 @@ export function ProfileScreen(): JSX.Element {
|
|
|
53
53
|
// `error` vinha sendo ignorado: o `useRequest` guarda a mensagem do servidor
|
|
54
54
|
// mas não notifica, então um 400 de validação ficava invisível — a tela não
|
|
55
55
|
// salvava e nada dizia por quê.
|
|
56
|
-
|
|
56
|
+
// Os dois calam o toast: esta tela desenha `error` acima do formulário, e
|
|
57
|
+
// a troca de senha desenha o dela dentro do bloco de senha.
|
|
58
|
+
const { run, loading, error, setError } = useRequest({ silent: true });
|
|
57
59
|
// useRequest próprio da troca de senha: o `run` compartilhado da tela zeraria
|
|
58
60
|
// esse erro a cada outra ação (salvar perfil, trocar avatar).
|
|
59
|
-
const password = useRequest();
|
|
61
|
+
const password = useRequest({ silent: true });
|
|
60
62
|
const { t } = useI18n();
|
|
61
63
|
|
|
62
64
|
const [avatar, setAvatar] = useState<string | null>(null);
|
|
@@ -35,7 +35,7 @@ export function ResetPasswordScreen(): JSX.Element {
|
|
|
35
35
|
const router = useRouter();
|
|
36
36
|
const params = useSearchParams();
|
|
37
37
|
const token = params.get("token") ?? "";
|
|
38
|
-
const { run, loading, error } = useRequest();
|
|
38
|
+
const { run, loading, error } = useRequest({ silent: true });
|
|
39
39
|
const { t } = useI18n();
|
|
40
40
|
const [done, setDone] = useState(false);
|
|
41
41
|
const [check, setCheck] = useState<PasswordTokenCheck | null>(null);
|
|
@@ -21,8 +21,6 @@ export interface Shortcut {
|
|
|
21
21
|
where: string;
|
|
22
22
|
/** Quando ele **não** dispara — a metade que evita surpresa. */
|
|
23
23
|
caveat: string;
|
|
24
|
-
/** Observação de teclado, quando a tecla tem outro nome em algum sistema. */
|
|
25
|
-
note?: string;
|
|
26
24
|
icon: React.ComponentType<{ className?: string }>;
|
|
27
25
|
}
|
|
28
26
|
|
|
@@ -88,9 +86,6 @@ export function ShortcutCard({ shortcut }: ShortcutCardProps): JSX.Element {
|
|
|
88
86
|
<Badge variant="secondary" className="w-fit font-normal">
|
|
89
87
|
{t(shortcut.caveat)}
|
|
90
88
|
</Badge>
|
|
91
|
-
{shortcut.note && (
|
|
92
|
-
<p className="text-xs text-muted-foreground">{t(shortcut.note)}</p>
|
|
93
|
-
)}
|
|
94
89
|
</div>
|
|
95
90
|
</Card>
|
|
96
91
|
);
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
Keyboard,
|
|
6
6
|
Maximize2,
|
|
7
7
|
PlusSquare,
|
|
8
|
+
Rows3,
|
|
8
9
|
XSquare,
|
|
9
10
|
} from "lucide-react";
|
|
10
11
|
import type { JSX } from "react";
|
|
@@ -15,8 +16,10 @@ import {
|
|
|
15
16
|
ShortcutCard,
|
|
16
17
|
} from "#core/features/shortcuts/components/shortcut-card";
|
|
17
18
|
import {
|
|
19
|
+
ALT_KEY_LABELS,
|
|
18
20
|
CREATE_SHORTCUT_KEYS,
|
|
19
|
-
|
|
21
|
+
EXPAND_SHORTCUT_KEY,
|
|
22
|
+
TAB_SHORTCUT_DIGITS,
|
|
20
23
|
} from "#core/hooks/use-shortcut";
|
|
21
24
|
|
|
22
25
|
/**
|
|
@@ -38,12 +41,20 @@ const SHORTCUTS: Shortcut[] = [
|
|
|
38
41
|
caveat: "shortcuts.create.caveat",
|
|
39
42
|
icon: PlusSquare,
|
|
40
43
|
},
|
|
44
|
+
{
|
|
45
|
+
name: "shortcuts.tabs.name",
|
|
46
|
+
// `Alt` e `⌥` são a mesma tecla: viram duas combinações e o card escreve
|
|
47
|
+
// "ou" entre elas, em vez de a diferença virar nota de rodapé.
|
|
48
|
+
combos: ALT_KEY_LABELS.map((alt) => [alt, TAB_SHORTCUT_DIGITS]),
|
|
49
|
+
where: "shortcuts.tabs.where",
|
|
50
|
+
caveat: "shortcuts.tabs.caveat",
|
|
51
|
+
icon: Rows3,
|
|
52
|
+
},
|
|
41
53
|
{
|
|
42
54
|
name: "shortcuts.expand.name",
|
|
43
|
-
combos:
|
|
55
|
+
combos: ALT_KEY_LABELS.map((alt) => [alt, EXPAND_SHORTCUT_KEY]),
|
|
44
56
|
where: "shortcuts.expand.where",
|
|
45
57
|
caveat: "shortcuts.expand.caveat",
|
|
46
|
-
note: "shortcuts.expand.note",
|
|
47
58
|
icon: Maximize2,
|
|
48
59
|
},
|
|
49
60
|
{
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ImageAccepted,
|
|
7
|
+
ImageJobSummary,
|
|
8
|
+
ImageProfileInfo,
|
|
9
|
+
imagesService,
|
|
10
|
+
} from "#core/_services/images";
|
|
11
|
+
import { useI18n } from "#core/contexts/i18n-context";
|
|
12
|
+
import { useJobs } from "#core/contexts/jobs-context";
|
|
13
|
+
|
|
14
|
+
export interface UseImageUploadResult {
|
|
15
|
+
/** O formato de saída que o servidor declarou. `null` enquanto carrega. */
|
|
16
|
+
profile: ImageProfileInfo | null;
|
|
17
|
+
sending: boolean;
|
|
18
|
+
error: string | null;
|
|
19
|
+
/** Envia um arquivo e devolve o caminho quando a conversão termina. */
|
|
20
|
+
upload: (file: File) => Promise<string | null>;
|
|
21
|
+
/** Manda o servidor buscar a imagem num endereço. */
|
|
22
|
+
uploadFromUrl: (url: string) => Promise<string | null>;
|
|
23
|
+
clearError: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Orquestra o envio de uma imagem: pergunta o perfil, valida o que dá para
|
|
28
|
+
* validar antes de gastar rede, envia e espera o desfecho.
|
|
29
|
+
*
|
|
30
|
+
* A espera não abre socket nenhum: ela registra uma sessão no `JobsProvider`,
|
|
31
|
+
* que já escuta a conexão do `AppShell` — e é a mesma sessão que o painel do
|
|
32
|
+
* rodapé mostra. Quando a resposta já traz o `path` (imagem pequena, ou
|
|
33
|
+
* ambiente sem fila), o job se encerra na hora, sem esperar evento que não vem.
|
|
34
|
+
*/
|
|
35
|
+
export function useImageUpload(profileName: string): UseImageUploadResult {
|
|
36
|
+
const { t } = useI18n();
|
|
37
|
+
const { start } = useJobs();
|
|
38
|
+
const [profile, setProfile] = useState<ImageProfileInfo | null>(null);
|
|
39
|
+
const [sending, setSending] = useState(false);
|
|
40
|
+
const [error, setError] = useState<string | null>(null);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let ativo = true;
|
|
44
|
+
|
|
45
|
+
void imagesService
|
|
46
|
+
.profile(profileName)
|
|
47
|
+
.then((data) => {
|
|
48
|
+
if (ativo) {
|
|
49
|
+
setProfile(data);
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
.catch(() => {
|
|
53
|
+
// Perfil que não responde não derruba a tela: o envio segue possível, e
|
|
54
|
+
// quem recusa de verdade é o servidor.
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
ativo = false;
|
|
59
|
+
};
|
|
60
|
+
}, [profileName]);
|
|
61
|
+
|
|
62
|
+
/** O caminho comum aos dois envios: sessão, espera e tradução do erro. */
|
|
63
|
+
const send = useCallback(
|
|
64
|
+
async (
|
|
65
|
+
label: string,
|
|
66
|
+
request: () => Promise<ImageAccepted>,
|
|
67
|
+
): Promise<string | null> => {
|
|
68
|
+
setSending(true);
|
|
69
|
+
setError(null);
|
|
70
|
+
|
|
71
|
+
const job = start<ImageJobSummary>(label);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const accepted = await request();
|
|
75
|
+
|
|
76
|
+
// Já terminou: encerra a sessão com o resultado em vez de esperar um
|
|
77
|
+
// evento que não vai chegar.
|
|
78
|
+
if (accepted.path) {
|
|
79
|
+
job.settle({ path: accepted.path, width: 0, height: 0, bytes: 0 });
|
|
80
|
+
return accepted.path;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!accepted.jobId) {
|
|
84
|
+
throw new Error(t("image.failed"));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
job.bind(accepted.jobId);
|
|
88
|
+
|
|
89
|
+
const summary = await job.done;
|
|
90
|
+
return summary.path;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const message = messageOf(err, t("image.failed"));
|
|
93
|
+
|
|
94
|
+
// `fail` é idempotente do ponto de vista de quem chama: se o job já
|
|
95
|
+
// terminou em falha (foi o socket que trouxe o erro), isto não duplica
|
|
96
|
+
// nada — mas se a falha foi no próprio HTTP, é o que tira a sessão do
|
|
97
|
+
// painel.
|
|
98
|
+
job.fail(message);
|
|
99
|
+
setError(message);
|
|
100
|
+
return null;
|
|
101
|
+
} finally {
|
|
102
|
+
setSending(false);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
[start, t],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const upload = useCallback(
|
|
109
|
+
async (file: File): Promise<string | null> => {
|
|
110
|
+
// As duas validações que dá para fazer aqui, e que evitam subir megabytes
|
|
111
|
+
// para receber 400: tipo e tamanho, ambos vindos do perfil.
|
|
112
|
+
if (profile && !profile.accept.includes(file.type)) {
|
|
113
|
+
setError(t("image.invalidType"));
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (profile && file.size > profile.maxBytes) {
|
|
117
|
+
setError(
|
|
118
|
+
t("image.tooLarge", {
|
|
119
|
+
size: Math.round(profile.maxBytes / (1024 * 1024)),
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return await send(file.name, () => imagesService.upload(profileName, file));
|
|
126
|
+
},
|
|
127
|
+
[profile, profileName, send, t],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const uploadFromUrl = useCallback(
|
|
131
|
+
async (url: string): Promise<string | null> =>
|
|
132
|
+
await send(labelOf(url), () => imagesService.fromUrl(profileName, url)),
|
|
133
|
+
[profileName, send],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
profile,
|
|
138
|
+
sending,
|
|
139
|
+
error,
|
|
140
|
+
upload,
|
|
141
|
+
uploadFromUrl,
|
|
142
|
+
clearError: () => setError(null),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** O nome no fim da URL, para rotular a barra. */
|
|
147
|
+
const labelOf = (url: string): string =>
|
|
148
|
+
url.split("?")[0].split("/").filter(Boolean).pop()?.slice(0, 60) ?? "imagem";
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A mensagem que o backend escreveu, quando existe.
|
|
152
|
+
*
|
|
153
|
+
* O core devolve texto pronto para ler em erro de negócio ("A imagem passa do
|
|
154
|
+
* limite de 8MB"), e trocá-lo por um genérico esconderia justamente o que a
|
|
155
|
+
* pessoa precisa saber para resolver.
|
|
156
|
+
*/
|
|
157
|
+
const messageOf = (error: unknown, fallback: string): string => {
|
|
158
|
+
const response = (error as { response?: { data?: { message?: unknown } } })
|
|
159
|
+
?.response?.data?.message;
|
|
160
|
+
|
|
161
|
+
if (typeof response === "string") {
|
|
162
|
+
return response;
|
|
163
|
+
}
|
|
164
|
+
if (Array.isArray(response) && typeof response[0] === "string") {
|
|
165
|
+
return response[0];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (error as Error)?.message || fallback;
|
|
169
|
+
};
|
package/src/hooks/use-request.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { AxiosError } from "axios";
|
|
4
|
-
import { Dispatch, SetStateAction, useCallback, useState } from "react";
|
|
4
|
+
import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
|
|
5
5
|
|
|
6
6
|
import { useI18n, useToast } from "#core/contexts";
|
|
7
7
|
|
|
@@ -34,6 +34,18 @@ export interface RunOptions {
|
|
|
34
34
|
* "Registro criado" (ex.: "Link de primeiro acesso enviado").
|
|
35
35
|
*/
|
|
36
36
|
success?: RequestOperation | string;
|
|
37
|
+
/**
|
|
38
|
+
* Cala o toast de erro — para a tela que **já desenha** o `error` na página.
|
|
39
|
+
*
|
|
40
|
+
* O padrão é avisar, e é o certo: falha silenciosa é o pior desfecho
|
|
41
|
+
* possível de uma escrita. A pessoa clica em "Pagar", nada acontece, e não há
|
|
42
|
+
* como distinguir "o servidor recusou" de "o botão está quebrado" — as duas
|
|
43
|
+
* hipóteses levam a caminhos diferentes e nenhuma delas é investigar o log.
|
|
44
|
+
*
|
|
45
|
+
* O login é o caso legítimo do contrário: ele mostra "Credenciais inválidas"
|
|
46
|
+
* dentro do formulário, e o toast por cima diria a mesma coisa duas vezes.
|
|
47
|
+
*/
|
|
48
|
+
silent?: boolean;
|
|
37
49
|
/**
|
|
38
50
|
* Reage ao erro com o `errorCode` em mãos — para quando a tela precisa fazer
|
|
39
51
|
* algo além de mostrar a mensagem (ex.: voltar ao passo de credenciais
|
|
@@ -59,14 +71,33 @@ export interface UseRequestResult {
|
|
|
59
71
|
* Hook genérico para chamadas assíncronas com estados de loading/erro.
|
|
60
72
|
* Ex.: const { run, loading, error } = useRequest();
|
|
61
73
|
* await run(() => authService.login(email, senha));
|
|
74
|
+
*
|
|
75
|
+
* Os `defaults` valem para todas as chamadas daquele hook, e cada `run` os
|
|
76
|
+
* sobrescreve. É o que evita repetir `silent: true` em dez chamadas de uma tela
|
|
77
|
+
* que desenha o erro na própria página — e repetir dez vezes é como uma delas
|
|
78
|
+
* fica para trás.
|
|
62
79
|
*/
|
|
63
|
-
export function useRequest(): UseRequestResult {
|
|
80
|
+
export function useRequest(defaults: RunOptions = {}): UseRequestResult {
|
|
64
81
|
const { t } = useI18n();
|
|
65
82
|
const { notify } = useToast();
|
|
66
83
|
const [loading, setLoading] = useState(false);
|
|
84
|
+
/*
|
|
85
|
+
Os padrões entram por ref, e não pela lista de dependências do `run`.
|
|
86
|
+
|
|
87
|
+
A tela escreve `useRequest({ silent: true })`, e um literal é objeto novo a
|
|
88
|
+
cada render: pela dependência, o `run` mudaria de identidade sempre, e todo
|
|
89
|
+
`useCallback`/`useEffect` que depende dele — a carga de uma listagem, por
|
|
90
|
+
exemplo — recarregaria em laço. O sintoma seria a tela piscando sozinha, a
|
|
91
|
+
quilômetros da causa.
|
|
92
|
+
*/
|
|
93
|
+
const defaultsRef = useRef(defaults);
|
|
67
94
|
const [error, setError] = useState<string | null>(null);
|
|
68
95
|
const [errorCode, setErrorCode] = useState<string | null>(null);
|
|
69
96
|
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
defaultsRef.current = defaults;
|
|
99
|
+
}, [defaults]);
|
|
100
|
+
|
|
70
101
|
/**
|
|
71
102
|
* Ordem de preferência da mensagem:
|
|
72
103
|
* 1. tradução do `errorCode` — texto do produto, no idioma do usuário;
|
|
@@ -103,13 +134,14 @@ export function useRequest(): UseRequestResult {
|
|
|
103
134
|
);
|
|
104
135
|
|
|
105
136
|
const run = useCallback(
|
|
106
|
-
async <T>(fn: () => Promise<T>,
|
|
137
|
+
async <T>(fn: () => Promise<T>, call?: RunOptions): Promise<T | null> => {
|
|
138
|
+
const options = { ...defaultsRef.current, ...call };
|
|
107
139
|
setLoading(true);
|
|
108
140
|
setError(null);
|
|
109
141
|
setErrorCode(null);
|
|
110
142
|
try {
|
|
111
143
|
const result = await fn();
|
|
112
|
-
if (options
|
|
144
|
+
if (options.success) {
|
|
113
145
|
notify(successMessage(options.success), "success");
|
|
114
146
|
}
|
|
115
147
|
return result;
|
|
@@ -120,7 +152,17 @@ export function useRequest(): UseRequestResult {
|
|
|
120
152
|
const code = body?.errorCode ?? null;
|
|
121
153
|
setError(message);
|
|
122
154
|
setErrorCode(code);
|
|
123
|
-
|
|
155
|
+
|
|
156
|
+
/*
|
|
157
|
+
O aviso vem antes do `onError` de propósito: quem passa o callback
|
|
158
|
+
costuma navegar ou fechar um modal ali dentro, e o toast disparado
|
|
159
|
+
depois disso apareceria sobre uma tela que já mudou.
|
|
160
|
+
*/
|
|
161
|
+
if (!options.silent) {
|
|
162
|
+
notify(message, "error");
|
|
163
|
+
}
|
|
164
|
+
options.onError?.({ code, message });
|
|
165
|
+
|
|
124
166
|
return null;
|
|
125
167
|
} finally {
|
|
126
168
|
setLoading(false);
|
|
@@ -47,6 +47,26 @@ export const EXPAND_SHORTCUT_KEY = "Enter";
|
|
|
47
47
|
/** Como ele se apresenta ao usuário. */
|
|
48
48
|
export const EXPAND_SHORTCUT_LABEL = "Alt + Enter";
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* O modificador do `Alt`, escrito das duas formas que os teclados usam.
|
|
52
|
+
*
|
|
53
|
+
* `Alt` e `⌥` são a **mesma tecla física**, e é por isso que aparecem como
|
|
54
|
+
* combinações alternativas na tela de atalhos em vez de virarem uma nota de
|
|
55
|
+
* rodapé: quem tem um teclado da Apple procura o símbolo, não a palavra.
|
|
56
|
+
*/
|
|
57
|
+
export const ALT_KEY_LABELS = ["Alt", "⌥"] as const;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Quantas abas o atalho numera.
|
|
61
|
+
*
|
|
62
|
+
* Nove porque `Alt + 0` não segue a sequência, e porque ninguém conta abas além
|
|
63
|
+
* disso sem olhar.
|
|
64
|
+
*/
|
|
65
|
+
export const MAX_TAB_SHORTCUTS = 9;
|
|
66
|
+
|
|
67
|
+
/** Como o atalho de abas se apresenta: `Alt` + `1…9`. */
|
|
68
|
+
export const TAB_SHORTCUT_DIGITS = `1…${MAX_TAB_SHORTCUTS}`;
|
|
69
|
+
|
|
50
70
|
export interface ShortcutOptions {
|
|
51
71
|
/**
|
|
52
72
|
* Exige duas batidas seguidas dentro de meio segundo.
|
|
@@ -85,6 +105,28 @@ export interface ShortcutOptions {
|
|
|
85
105
|
* `false` quando a tecla sem ele significar outra coisa.
|
|
86
106
|
*/
|
|
87
107
|
shift?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Compara a **posição física** da tecla (`event.code`), e não o caractere.
|
|
110
|
+
*
|
|
111
|
+
* Existe por causa do `Alt`: no macOS, `Option` + número **não** produz o
|
|
112
|
+
* número — produz um símbolo. `⌥1` chega como `¡`, `⌥2` como `™`, `⌥3` como
|
|
113
|
+
* `£`, e o atalho declarado como `"1"` nunca dispara. No Windows e no Linux
|
|
114
|
+
* ele dispararia, o que é pior que não funcionar em lugar nenhum: quebra só
|
|
115
|
+
* na máquina de quem usa Mac.
|
|
116
|
+
*
|
|
117
|
+
* `event.code` é imune a isso e a layout: a tecla ao lado do `Q` é `KeyW` no
|
|
118
|
+
* QWERTY e no AZERTY, e o `1` é `Digit1` com ou sem modificador.
|
|
119
|
+
*
|
|
120
|
+
* Em troca, ele **ignora o layout de propósito** — e é justamente por isso
|
|
121
|
+
* que não é o padrão. Um atalho de letra declarado por `code` cai na tecla
|
|
122
|
+
* errada de quem usa Dvorak, enquanto por `key` ele segue a letra que a
|
|
123
|
+
* pessoa vê impressa. Use `byCode` para dígito com modificador; para letra,
|
|
124
|
+
* continue em `key`.
|
|
125
|
+
*
|
|
126
|
+
* Os valores são os do padrão: `Digit1`..`Digit9`, `KeyA`..`KeyZ`,
|
|
127
|
+
* `Numpad1`, `Enter`, `Space`.
|
|
128
|
+
*/
|
|
129
|
+
byCode?: boolean;
|
|
88
130
|
/**
|
|
89
131
|
* Deixa o atalho valer também com o cursor dentro de um campo.
|
|
90
132
|
*
|
|
@@ -146,6 +188,7 @@ export function useShortcut(
|
|
|
146
188
|
shift,
|
|
147
189
|
allowInDialog = false,
|
|
148
190
|
allowWhileTyping = false,
|
|
191
|
+
byCode = false,
|
|
149
192
|
} = options;
|
|
150
193
|
|
|
151
194
|
// Serializado para o efeito não se reinscrever a cada render quando a tela
|
|
@@ -179,7 +222,12 @@ export function useShortcut(
|
|
|
179
222
|
const accepted = keySignature.split("\u0000");
|
|
180
223
|
|
|
181
224
|
const onKeyDown = (event: KeyboardEvent): void => {
|
|
182
|
-
|
|
225
|
+
// A mesma leitura serve para a comparação e para a janela do duplo: são
|
|
226
|
+
// "a mesma tecla", e ler uma por `code` e outra por `key` faria a segunda
|
|
227
|
+
// batida de um atalho por posição nunca casar com a primeira.
|
|
228
|
+
const pressed = byCode ? event.code : event.key;
|
|
229
|
+
|
|
230
|
+
if (!accepted.includes(pressed)) {
|
|
183
231
|
return;
|
|
184
232
|
}
|
|
185
233
|
if (event.altKey !== alt) {
|
|
@@ -203,10 +251,10 @@ export function useShortcut(
|
|
|
203
251
|
if (double) {
|
|
204
252
|
const now = Date.now();
|
|
205
253
|
const previous = lastPressRef.current;
|
|
206
|
-
lastPressRef.current = { key:
|
|
254
|
+
lastPressRef.current = { key: pressed, at: now };
|
|
207
255
|
|
|
208
256
|
if (
|
|
209
|
-
previous.key !==
|
|
257
|
+
previous.key !== pressed ||
|
|
210
258
|
now - previous.at > DOUBLE_PRESS_WINDOW_MS
|
|
211
259
|
) {
|
|
212
260
|
return;
|
|
@@ -244,6 +292,7 @@ export function useShortcut(
|
|
|
244
292
|
shift,
|
|
245
293
|
allowInDialog,
|
|
246
294
|
allowWhileTyping,
|
|
295
|
+
byCode,
|
|
247
296
|
]);
|
|
248
297
|
}
|
|
249
298
|
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -91,6 +91,21 @@ export const en: Messages = {
|
|
|
91
91
|
running: "Processing…",
|
|
92
92
|
completed: "Processing finished",
|
|
93
93
|
failed: "The processing could not be completed",
|
|
94
|
+
dockRunning: "{count} in progress",
|
|
95
|
+
dockFailed: "Something went wrong",
|
|
96
|
+
},
|
|
97
|
+
image: {
|
|
98
|
+
empty: "Drag an image, paste it, or",
|
|
99
|
+
choose: "choose a file",
|
|
100
|
+
urlLabel: "Or paste an image address",
|
|
101
|
+
urlAction: "Fetch",
|
|
102
|
+
remove: "Remove image",
|
|
103
|
+
replace: "Replace image",
|
|
104
|
+
sending: "Sending…",
|
|
105
|
+
tooLarge: "The image exceeds the {size}MB limit",
|
|
106
|
+
invalidType: "Invalid format — use JPEG, PNG or WebP",
|
|
107
|
+
failed: "The image could not be sent",
|
|
108
|
+
hint: "The image is converted to {format} at up to {width}px.",
|
|
94
109
|
},
|
|
95
110
|
queues: {
|
|
96
111
|
title: "Queues",
|
|
@@ -127,12 +142,17 @@ export const en: Messages = {
|
|
|
127
142
|
where: "On any listing with a create button.",
|
|
128
143
|
caveat: "Does not fire inside a text field or while a dialog is open",
|
|
129
144
|
},
|
|
145
|
+
tabs: {
|
|
146
|
+
name: "Switch tab",
|
|
147
|
+
where:
|
|
148
|
+
"On any screen with tabs, the number opens the tab in that position — 1 is the first.",
|
|
149
|
+
caveat: "Does not fire inside a text field or with a modal open",
|
|
150
|
+
},
|
|
130
151
|
expand: {
|
|
131
152
|
name: "Expand the dialog",
|
|
132
153
|
where:
|
|
133
154
|
"With a dialog open, toggles between normal and expanded size. Works anywhere in the dialog, like Esc.",
|
|
134
155
|
caveat: "Works in any field, and does not submit the form",
|
|
135
|
-
note: "On a Mac, Alt is the Option key (⌥) — most keyboards print both labels on it.",
|
|
136
156
|
},
|
|
137
157
|
submit: {
|
|
138
158
|
name: "Submit the form",
|
|
@@ -155,6 +175,8 @@ export const en: Messages = {
|
|
|
155
175
|
},
|
|
156
176
|
table: {
|
|
157
177
|
columns: "Columns",
|
|
178
|
+
selectAll: "Select all rows on this page",
|
|
179
|
+
selectRow: "Select this row",
|
|
158
180
|
columnsButton: "Columns",
|
|
159
181
|
columnsTitle: "Configure column visibility",
|
|
160
182
|
columnsHint: "Select the columns you want to see in the table.",
|
|
@@ -178,6 +200,7 @@ export const en: Messages = {
|
|
|
178
200
|
hint: "Fill in only the fields you want to use. All of them are combined with AND.",
|
|
179
201
|
apply: "Apply",
|
|
180
202
|
clear: "Clear",
|
|
203
|
+
close: "Close",
|
|
181
204
|
empty: "This listing has no filters yet.",
|
|
182
205
|
noOptions: "No options available",
|
|
183
206
|
all: "All",
|
|
@@ -236,6 +259,9 @@ export const en: Messages = {
|
|
|
236
259
|
title: "Notifications",
|
|
237
260
|
all: "All",
|
|
238
261
|
unread: "Unread",
|
|
262
|
+
read: "Read",
|
|
263
|
+
clearRead: "Clear read",
|
|
264
|
+
dismiss: "Remove from list",
|
|
239
265
|
markAllRead: "Mark all as read",
|
|
240
266
|
empty: "No notifications",
|
|
241
267
|
},
|