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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/_services/api/schema.d.ts +267 -4
  3. package/src/_services/images/index.ts +81 -0
  4. package/src/_utils/upload-url.ts +27 -0
  5. package/src/components/index.ts +1 -0
  6. package/src/components/job-dock.tsx +118 -0
  7. package/src/components/ui/data-table.tsx +113 -2
  8. package/src/components/ui/image-upload.tsx +329 -0
  9. package/src/components/ui/index.ts +2 -0
  10. package/src/components/ui/tab-shortcuts.tsx +77 -0
  11. package/src/components/user-avatar.tsx +7 -13
  12. package/src/contexts/index.ts +2 -0
  13. package/src/contexts/jobs-context.tsx +146 -0
  14. package/src/contexts/jobs-store.ts +391 -0
  15. package/src/features/login/hooks/use-login-flow.ts +1 -1
  16. package/src/features/notifications/components/notification-item.tsx +34 -10
  17. package/src/features/notifications/hooks/use-notifications.ts +32 -1
  18. package/src/features/notifications/notifications-center.tsx +37 -15
  19. package/src/features/notifications/services/notifications.service.ts +10 -0
  20. package/src/features/profile/force-password-change.tsx +1 -1
  21. package/src/features/profile/profile-screen.tsx +4 -2
  22. package/src/features/recovery/reset-password-screen.tsx +1 -1
  23. package/src/features/shortcuts/components/shortcut-card.tsx +0 -5
  24. package/src/features/shortcuts/shortcuts-screen.tsx +14 -3
  25. package/src/hooks/use-image-upload.ts +169 -0
  26. package/src/hooks/use-request.ts +47 -5
  27. package/src/hooks/use-shortcut.ts +52 -3
  28. package/src/i18n/messages/en.ts +27 -1
  29. package/src/i18n/messages/pt.ts +27 -1
  30. package/src/index.ts +32 -0
  31. 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 = tab === "unread" ? items.filter((i) => !i.read) : items;
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 === "all"
79
- ? t("notifications.all")
80
- : t("notifications.unread")}
85
+ {t(`notifications.${value}`)}
81
86
  </button>
82
87
  ))}
83
88
  </div>
84
89
 
85
- <button
86
- type="button"
87
- onClick={() => void markAllAsRead()}
88
- disabled={unread === 0}
89
- className="text-xs font-medium text-primary hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline"
90
- >
91
- {t("notifications.markAllRead")}
92
- </button>
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
- const { run, loading, error, setError } = useRequest();
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
- EXPAND_SHORTCUT_LABEL,
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: [EXPAND_SHORTCUT_LABEL.split(" + ")],
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
+ };
@@ -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>, options?: RunOptions): Promise<T | null> => {
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?.success) {
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
- options?.onError?.({ code, message });
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
- if (!accepted.includes(event.key)) {
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: event.key, at: now };
254
+ lastPressRef.current = { key: pressed, at: now };
207
255
 
208
256
  if (
209
- previous.key !== event.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
 
@@ -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
  },