@ciromaciel/dashboard-kit 1.0.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.
Files changed (38) hide show
  1. package/README.md +40 -0
  2. package/package.json +46 -0
  3. package/src/auth-labels.js +77 -0
  4. package/src/components/ActiveResource.jsx +68 -0
  5. package/src/components/AuthLoader.jsx +66 -0
  6. package/src/components/AuthScreen.jsx +32 -0
  7. package/src/components/CardSkeleton.jsx +82 -0
  8. package/src/components/CommandCenter.css +57 -0
  9. package/src/components/CommandCenter.jsx +185 -0
  10. package/src/components/DashboardFrame.jsx +97 -0
  11. package/src/components/DataTable.jsx +153 -0
  12. package/src/components/DeleteModal.jsx +341 -0
  13. package/src/components/EmptyState.jsx +55 -0
  14. package/src/components/GroupLabel.jsx +28 -0
  15. package/src/components/HoldToConfirm.jsx +151 -0
  16. package/src/components/ListSkeleton.jsx +55 -0
  17. package/src/components/MenuItem.jsx +56 -0
  18. package/src/components/Metric.jsx +178 -0
  19. package/src/components/MoreResults.jsx +137 -0
  20. package/src/components/NavItem.jsx +57 -0
  21. package/src/components/PageHeader.jsx +124 -0
  22. package/src/components/PlanBanner.jsx +88 -0
  23. package/src/components/ResourceItem.jsx +93 -0
  24. package/src/components/SectionCard.jsx +212 -0
  25. package/src/components/SidebarShell.jsx +271 -0
  26. package/src/components/SupportModal.jsx +269 -0
  27. package/src/components/TableSkeleton.jsx +43 -0
  28. package/src/components/TableToolbar.jsx +136 -0
  29. package/src/components/Terminal.jsx +217 -0
  30. package/src/components/Wordmark.jsx +52 -0
  31. package/src/components/ZenLabel.jsx +30 -0
  32. package/src/components/notify-theme.js +103 -0
  33. package/src/components/notify.js +65 -0
  34. package/src/components/table-grid.js +27 -0
  35. package/src/hooks/useExclusiveModal.js +49 -0
  36. package/src/hooks/usePaginatedList.js +206 -0
  37. package/src/index.js +99 -0
  38. package/src/tokens.css +47 -0
@@ -0,0 +1,271 @@
1
+ import { ActionIcon, Box, ScrollArea, Stack, Text, TextInput } from '@mantine/core'
2
+ import { IconSearch, IconX } from '@tabler/icons-react'
3
+
4
+ import { GroupLabel } from './GroupLabel'
5
+ import { ListSkeleton } from './ListSkeleton'
6
+ import { NavItem } from './NavItem'
7
+
8
+ /**
9
+ * A coluna lateral de um painel, por CONFIGURAÇÃO em vez de por cópia.
10
+ *
11
+ * A comparação que originou este componente: `payments` e `messages` tinham 596
12
+ * de 612 linhas de código IDÊNTICAS (97,4%), e as 16 que diferiam eram o array
13
+ * de seções, o import de ícones e um identificador traduzido. Os outros cinco
14
+ * seguiam o mesmo desenho com outros substantivos.
15
+ *
16
+ * A FORMA DA COLUNA É UMA SÓ, e ela conta uma história em duas metades:
17
+ *
18
+ * FORA de um recurso — busca, ação de criar, e a lista do que existe.
19
+ * DENTRO de um recurso — o nome dele, as seções DELE, e a volta.
20
+ *
21
+ * As duas nunca convivem: empilhá-las fazia a coluna rolar e escondia as
22
+ * seções, que são o motivo de se ter entrado no recurso.
23
+ *
24
+ * OS TRÊS SLOTS DE EXTENSÃO existem porque três painéis precisam de algo que os
25
+ * outros quatro não têm — e cada um é usado UMA vez, que é o teste de "isto é
26
+ * do produto":
27
+ *
28
+ * `renderGroups` o Auth tem dois níveis (organização → aplicação) e precisa
29
+ * de cabeçalho de grupo interativo.
30
+ * `partition` o Hoster fatia a lista em Fixados/Recentes/Todos — a única
31
+ * resposta na casa ao problema "dezenove projetos não cabem".
32
+ * `footer` o cartão de usuário, que o Functions monta diferente por
33
+ * causa do modo `dev-user`, sem sessão do Auth.
34
+ *
35
+ * A BUSCA APARECE POR LIMIAR, não sempre: com três itens ela é cromo, e o custo
36
+ * de não tê-la é zero. `searchThreshold: 0` a mantém fixa; `null` a remove (é o
37
+ * caso do Monitors, que não tem lista).
38
+ *
39
+ * Nada de quota entra aqui. O painel resolve o gate e passa `createAllowed`
40
+ * booleano — a coluna não sabe o que é um plano.
41
+ */
42
+ export function SidebarShell({
43
+ // ── fora do recurso ──────────────────────────────────────────────────
44
+ items = [],
45
+ loading = false,
46
+ groupLabel,
47
+ emptyLabel = 'Nenhum item',
48
+ emptySearchLabel = 'Nenhum resultado',
49
+ searchPlaceholder,
50
+ searchThreshold = 0,
51
+ searchFields = ['name'],
52
+ term = '',
53
+ onTermChange,
54
+ renderItem,
55
+ renderGroups,
56
+ partition,
57
+ // ── a ação de criar ──────────────────────────────────────────────────
58
+ createIcon,
59
+ createLabel,
60
+ createBlockedLabel,
61
+ createAllowed = true,
62
+ onCreate,
63
+ onCreateBlocked,
64
+ // ── dentro do recurso ────────────────────────────────────────────────
65
+ activeResource,
66
+ backIcon,
67
+ backLabel,
68
+ onBack,
69
+ sections = [],
70
+ currentSection,
71
+ onSectionChange,
72
+ // ── o que fica em volta ──────────────────────────────────────────────
73
+ header,
74
+ accountNav,
75
+ footer,
76
+ children,
77
+ }) {
78
+ const busca = term.trim().toLowerCase()
79
+
80
+ const filtrados = busca
81
+ ? items.filter(item =>
82
+ searchFields.some(campo =>
83
+ String(item[campo] ?? '')
84
+ .toLowerCase()
85
+ .includes(busca),
86
+ ),
87
+ )
88
+ : items
89
+
90
+ // `null` remove a busca; `0` a mantém fixa; um número a revela a partir
91
+ // dali. Quem já digitou continua vendo o campo mesmo que o filtro tenha
92
+ // reduzido a lista abaixo do limiar — tirá-lo no meio da digitação seria
93
+ // arrancar o que a pessoa está usando.
94
+ const mostrarBusca = searchThreshold !== null && !activeResource && (items.length >= searchThreshold || Boolean(term))
95
+
96
+ const faixas = partition ? partition(filtrados) : null
97
+
98
+ return (
99
+ <Stack
100
+ gap={2}
101
+ h="100%"
102
+ p="16px 12px"
103
+ align="stretch"
104
+ w="100%"
105
+ style={{ overflow: 'hidden' }}
106
+ >
107
+ {header}
108
+
109
+ {mostrarBusca && (
110
+ <Box
111
+ px={12}
112
+ pb={8}
113
+ w="100%"
114
+ >
115
+ <TextInput
116
+ value={term}
117
+ onChange={e => onTermChange?.(e.currentTarget.value)}
118
+ onKeyDown={e => e.key === 'Escape' && onTermChange?.('')}
119
+ placeholder={searchPlaceholder}
120
+ aria-label={searchPlaceholder}
121
+ leftSection={
122
+ <IconSearch
123
+ size={14}
124
+ stroke={1.5}
125
+ />
126
+ }
127
+ rightSection={
128
+ term ? (
129
+ <ActionIcon
130
+ size="xs"
131
+ color="gray"
132
+ variant="transparent"
133
+ onClick={() => onTermChange?.('')}
134
+ aria-label="Limpar busca"
135
+ >
136
+ <IconX
137
+ size={14}
138
+ stroke={1.5}
139
+ />
140
+ </ActionIcon>
141
+ ) : null
142
+ }
143
+ styles={{ input: { fontSize: 12, background: 'var(--mantine-color-gray-1)' } }}
144
+ />
145
+ </Box>
146
+ )}
147
+
148
+ {activeResource ? (
149
+ <NavItem
150
+ icon={backIcon}
151
+ label={backLabel}
152
+ onClick={onBack}
153
+ />
154
+ ) : (
155
+ <>
156
+ {/*
157
+ Criar fica ANTES da lista: com muitos recursos a lista
158
+ rola, e uma ação no fim de uma área de rolagem é uma ação
159
+ que se procura.
160
+
161
+ No limite do plano o item NÃO some — ele leva para os
162
+ planos. Escondê-lo deixaria o limite invisível, e a pessoa
163
+ procuraria o botão em vez de descobrir que precisa de
164
+ upgrade.
165
+ */}
166
+ {onCreate && (
167
+ <NavItem
168
+ icon={createIcon}
169
+ label={createAllowed ? createLabel : createBlockedLabel}
170
+ muted={!createAllowed}
171
+ onClick={createAllowed ? onCreate : onCreateBlocked}
172
+ />
173
+ )}
174
+
175
+ {groupLabel && <GroupLabel>{groupLabel}</GroupLabel>}
176
+ </>
177
+ )}
178
+
179
+ {/*
180
+ A lista rola sozinha e o resto da coluna fica parado: com muitos
181
+ recursos, o cartão do usuário continua onde o dedo espera achá-lo.
182
+
183
+ `scrollbars="y"` porque a rolagem horizontal não deve existir numa
184
+ coluna de largura fixa — e ela aparecia, com o conteúdo mais largo
185
+ ditando a largura da coluna.
186
+ */}
187
+ {!activeResource && (
188
+ <ScrollArea.Autosize
189
+ style={{ flex: 1, width: '100%' }}
190
+ type="auto"
191
+ scrollbars="y"
192
+ className="zen-sidebar-scroll"
193
+ >
194
+ <Stack
195
+ gap={2}
196
+ w="100%"
197
+ >
198
+ {loading ? (
199
+ <ListSkeleton />
200
+ ) : filtrados.length === 0 ? (
201
+ <Box py={16}>
202
+ <Text
203
+ fz={10}
204
+ fw={800}
205
+ c="gray.4"
206
+ tt="uppercase"
207
+ lts="1.5px"
208
+ ta="center"
209
+ >
210
+ {term ? emptySearchLabel : emptyLabel}
211
+ </Text>
212
+ </Box>
213
+ ) : renderGroups ? (
214
+ renderGroups(filtrados)
215
+ ) : faixas ? (
216
+ faixas.map(faixa => (
217
+ <Box key={faixa.label ?? 'sem-faixa'}>
218
+ {faixa.label && <GroupLabel>{faixa.label}</GroupLabel>}
219
+ {faixa.items.map(renderItem)}
220
+ </Box>
221
+ ))
222
+ ) : (
223
+ filtrados.map(renderItem)
224
+ )}
225
+ </Stack>
226
+ </ScrollArea.Autosize>
227
+ )}
228
+
229
+ <Stack
230
+ gap={2}
231
+ flex={activeResource ? 1 : 'none'}
232
+ w="100%"
233
+ align="center"
234
+ >
235
+ {activeResource && (
236
+ <>
237
+ {activeResource}
238
+ {sections.map(item => (
239
+ <NavItem
240
+ key={item.id}
241
+ icon={item.icon}
242
+ label={item.label}
243
+ active={currentSection === item.id}
244
+ onClick={() => onSectionChange?.(item)}
245
+ />
246
+ ))}
247
+ </>
248
+ )}
249
+ </Stack>
250
+
251
+ {accountNav}
252
+
253
+ {/*
254
+ O rodapé é o cartão do usuário: Docs e Suporte vivem dentro do
255
+ menu dele, junto de Conta e Assinatura. Na coluna, competiriam com
256
+ a lista — que é o assunto da coluna.
257
+ */}
258
+ {footer && (
259
+ <Box
260
+ pt={16}
261
+ w="100%"
262
+ style={{ borderTop: 'var(--rlg-border)' }}
263
+ >
264
+ {footer}
265
+ </Box>
266
+ )}
267
+
268
+ {children}
269
+ </Stack>
270
+ )
271
+ }
@@ -0,0 +1,269 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Box, Button, Group, Modal, ScrollArea, Stack, Text, Title } from '@mantine/core'
3
+ import { IconMail } from '@tabler/icons-react'
4
+
5
+ import { ZenLabel } from './ZenLabel'
6
+
7
+ /**
8
+ * Support — one e-mail, with the technical context already attached, and the
9
+ * API's real state measured on the spot.
10
+ *
11
+ * This modal operates nothing. It convinces you there is someone on the other
12
+ * side, which is why the display is 36px.
13
+ *
14
+ * ONE FORM, NO EXCEPTIONS — a deliberate call, and it costs something.
15
+ *
16
+ * Four dashboards carried a copy. Messages and Payments were the same file
17
+ * except for one string. The other two had diverged, and adopting this drops
18
+ * what made them different:
19
+ *
20
+ * - Monitors rendered the state through `StatusDot`, its only emitter of
21
+ * state colour, with FOUR values (up, degraded, down, no data). Here there
22
+ * are three, so "degraded" no longer has its own reading in this modal.
23
+ * `StatusDot` stays in charge everywhere else in that dashboard.
24
+ * - Hoster ran this modal through `react-i18next` and built its context as a
25
+ * padEnd-aligned table. The text is Portuguese literal now, like the other
26
+ * six panels.
27
+ *
28
+ * The only thing a product still supplies is `contextRows` — the lines that
29
+ * name ITS resources (a project, a domain, a monitor). Everything else is the
30
+ * same in all four.
31
+ *
32
+ * @param {string} productName Names the mail subject: "Suporte · RiLiGar <name>".
33
+ * @param {string} apiUrl The root that gets measured. `GET /` must be public.
34
+ * @param {string[]} contextRows Lines appended to the mail body, already formatted.
35
+ */
36
+
37
+ const DEFAULT_SUPPORT_EMAIL = 'team@myinfrastructure.click'
38
+
39
+ /**
40
+ * Measures the API when the modal opens, and again on every opening — the
41
+ * state ten minutes ago does not describe the state now.
42
+ *
43
+ * `status` leaves 'idle' on the RESPONSE, not before it: writing 'loading' in
44
+ * the effect body would be a synchronous setState, a cascading render to say
45
+ * what the absence of a response already says. The status line treats 'idle'
46
+ * and 'loading' as the same "verificando…".
47
+ */
48
+ export function useHealthCheck(enabled, apiUrl) {
49
+ const [health, setHealth] = useState({ status: 'idle' })
50
+
51
+ useEffect(() => {
52
+ if (!enabled || !apiUrl) return
53
+
54
+ let cancelled = false
55
+ const startedAt = performance.now()
56
+
57
+ fetch(`${apiUrl}/`, { cache: 'no-store' })
58
+ .then(response => {
59
+ const elapsed = Math.round(performance.now() - startedAt)
60
+ if (cancelled) return
61
+ if (!response.ok) {
62
+ setHealth({ status: 'down', code: response.status, elapsed })
63
+ return
64
+ }
65
+ setHealth({ status: 'up', elapsed })
66
+ })
67
+ .catch(() => {
68
+ if (cancelled) return
69
+ setHealth({ status: 'down' })
70
+ })
71
+
72
+ return () => {
73
+ cancelled = true
74
+ setHealth({ status: 'idle' })
75
+ }
76
+ }, [enabled, apiUrl])
77
+
78
+ return health
79
+ }
80
+
81
+ /**
82
+ * The status light.
83
+ *
84
+ * `--rlg-status-up` is the system's only green, and it exists because this is
85
+ * NOT the success of an action (that is gray.9 + IconCheck): it is the measured
86
+ * state of an external system, and it never appears without the word next to
87
+ * it.
88
+ */
89
+ export function HealthRow({ health, host }) {
90
+ const label = health.status === 'up' ? 'No ar' : health.status === 'down' ? 'Sem resposta' : 'Verificando…'
91
+ const color = health.status === 'up' ? 'var(--rlg-status-up)' : health.status === 'down' ? 'var(--mantine-color-red-6)' : 'var(--mantine-color-gray-4)'
92
+
93
+ return (
94
+ <Group
95
+ justify="space-between"
96
+ wrap="nowrap"
97
+ gap={12}
98
+ px={{ base: 16, sm: 20 }}
99
+ py={14}
100
+ style={{ border: 'var(--rlg-border)', background: 'var(--mantine-color-gray-1)' }}
101
+ >
102
+ <Group
103
+ gap={8}
104
+ wrap="nowrap"
105
+ miw={0}
106
+ >
107
+ <Box
108
+ w={7}
109
+ h={7}
110
+ style={{ borderRadius: '50%', background: color, flex: 'none' }} // design-system-ignore: semáforo de status
111
+ />
112
+ <Text
113
+ fz={12}
114
+ fw={600}
115
+ c="gray.9"
116
+ lineClamp={1}
117
+ >
118
+ {label}
119
+ </Text>
120
+ </Group>
121
+ <Text
122
+ fz={10}
123
+ fw={800}
124
+ c="gray.4"
125
+ ff="monospace"
126
+ style={{ whiteSpace: 'nowrap' }}
127
+ >
128
+ {host}
129
+ {health.elapsed ? ` · ${health.elapsed}ms` : ''}
130
+ </Text>
131
+ </Group>
132
+ )
133
+ }
134
+
135
+ export function SupportModal({
136
+ opened,
137
+ onClose,
138
+ productName,
139
+ apiUrl,
140
+ contextRows = [],
141
+ supportEmail = DEFAULT_SUPPORT_EMAIL,
142
+ label = '+ Suporte',
143
+ title = 'Fale com quem',
144
+ titleTail = 'escreveu o código.',
145
+ description = 'Não há fila nem robô. O e-mail abaixo já vai com o contexto técnico anexado — conta, projeto e o estado da API medido agora.',
146
+ subject,
147
+ }) {
148
+ const health = useHealthCheck(opened, apiUrl)
149
+
150
+ /*
151
+ * A warning, not an exception — same posture as `Wordmark`, and for the same
152
+ * reason. Without `productName` the mail subject reads "Suporte · RiLiGar
153
+ * undefined"; without `apiUrl` the status line measures nothing and the body
154
+ * reports "não verificado". Neither breaks the build, and neither shows up
155
+ * until someone reads the e-mail that arrived.
156
+ *
157
+ * Verified by sabotage: removing `productName` from a panel's adapter builds
158
+ * green and ships the broken subject.
159
+ */
160
+ if (typeof console !== 'undefined') {
161
+ if (!productName) console.warn('[dashboard-kit] <SupportModal> without `productName`: the mail subject reads "Suporte · RiLiGar undefined". Pass `productName`.')
162
+ if (!apiUrl) console.warn('[dashboard-kit] <SupportModal> without `apiUrl`: nothing is measured and the e-mail reports "não verificado".')
163
+ }
164
+
165
+ /** The host shown on the status line — the domain, without the protocol. */
166
+ const host = apiUrl?.replace(/^https?:\/\//, '').replace(/\/$/, '')
167
+
168
+ const apiState = health.status === 'up' ? `no ar (${health.elapsed}ms)` : health.status === 'down' ? 'sem resposta' : 'não verificado'
169
+
170
+ /*
171
+ * The body goes out already filled in with what always gets asked back.
172
+ * `contextRows` is what the product adds — it is the only part that differs,
173
+ * and passing it in beats maintaining four copies of the whole modal.
174
+ */
175
+ const body = ['Descreva o que aconteceu:', '', '', '---', ...contextRows, `API: ${host}`, `Estado da API: ${apiState}`, `Navegador: ${navigator.userAgent}`].join('\n')
176
+
177
+ const mailSubject = subject ?? `Suporte · RiLiGar ${productName}`
178
+ const mailto = `mailto:${supportEmail}?subject=${encodeURIComponent(mailSubject)}&body=${encodeURIComponent(body)}`
179
+
180
+ return (
181
+ <Modal
182
+ opened={opened}
183
+ onClose={onClose}
184
+ size={620}
185
+ padding={0}
186
+ withCloseButton={false}
187
+ scrollAreaComponent={ScrollArea.Autosize}
188
+ >
189
+ <Stack
190
+ gap={0}
191
+ p={{ base: 20, sm: 32 }}
192
+ >
193
+ <ZenLabel>{label}</ZenLabel>
194
+
195
+ <Title
196
+ order={2}
197
+ fw={900}
198
+ fz={{ base: 26, sm: 36 }}
199
+ lh={1.05}
200
+ mt={12}
201
+ ls="-0.04em"
202
+ c="gray.9"
203
+ >
204
+ {title}{' '}
205
+ <Text
206
+ span
207
+ inherit
208
+ c="gray.6"
209
+ >
210
+ {titleTail}
211
+ </Text>
212
+ </Title>
213
+
214
+ <Text
215
+ fz={14}
216
+ c="gray.6"
217
+ lh={1.65}
218
+ maw={520}
219
+ mt={14}
220
+ >
221
+ {description}
222
+ </Text>
223
+
224
+ <Box
225
+ mt={28}
226
+ pt={24}
227
+ style={{ borderTop: 'var(--rlg-border)' }}
228
+ >
229
+ <Stack gap={16}>
230
+ <HealthRow
231
+ health={health}
232
+ host={host}
233
+ />
234
+
235
+ <Group gap={8}>
236
+ <Button
237
+ color="gray.9"
238
+ component="a"
239
+ href={mailto}
240
+ leftSection={
241
+ <IconMail
242
+ size={16}
243
+ stroke={1.5}
244
+ />
245
+ }
246
+ >
247
+ Escrever para o suporte
248
+ </Button>
249
+ <Button
250
+ variant="outline"
251
+ color="gray.9"
252
+ onClick={onClose}
253
+ >
254
+ Fechar
255
+ </Button>
256
+ </Group>
257
+
258
+ <Text
259
+ fz={12}
260
+ c="gray.5"
261
+ >
262
+ {supportEmail}
263
+ </Text>
264
+ </Stack>
265
+ </Box>
266
+ </Stack>
267
+ </Modal>
268
+ )
269
+ }
@@ -0,0 +1,43 @@
1
+ import { Box, Skeleton } from '@mantine/core'
2
+ import { useMediaQuery } from '@mantine/hooks'
3
+ import { templateFor, ROW_PADDING } from './table-grid'
4
+
5
+ /**
6
+ * Skeleton beats spinner: it reproduces the REAL grid of the content, so the
7
+ * layout doesn't jump when the data arrives (Law 3 — and one of the spec's
8
+ * blockers: "metric or table without a skeleton shaped like the content").
9
+ *
10
+ * That's why it takes the SAME `columns` as the DataTable and derives the
11
+ * gridTemplateColumns and the padding from them — there is no way for the two
12
+ * grids to diverge, because they are the same expression.
13
+ */
14
+ export function TableSkeleton({ columns, rows = 5 }) {
15
+ const wide = useMediaQuery('(min-width: 48em)', true)
16
+ const visible = wide ? columns : columns.filter(col => !col.hideBelow)
17
+ const template = templateFor(visible)
18
+
19
+ return Array.from({ length: rows }, (_, i) => (
20
+ <Box
21
+ key={i}
22
+ style={{
23
+ display: 'grid',
24
+ gridTemplateColumns: template,
25
+ gap: 16,
26
+ alignItems: 'center',
27
+ padding: ROW_PADDING,
28
+ borderBottom: i === rows - 1 ? 'none' : 'var(--rlg-border)',
29
+ }}
30
+ >
31
+ {visible.map(col => (
32
+ <Box key={col.key}>
33
+ {col.label || col.skeleton ? (
34
+ <Skeleton
35
+ height={14}
36
+ width={col.skeletonWidth || '60%'}
37
+ />
38
+ ) : null}
39
+ </Box>
40
+ ))}
41
+ </Box>
42
+ ))
43
+ }