@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,124 @@
1
+ import { Group, Stack, Text, Title } from '@mantine/core'
2
+ import { useMediaQuery } from '@mantine/hooks'
3
+
4
+ /**
5
+ * The canonical pair in dashboard form: a small label pressed against a heavy
6
+ * title.
7
+ *
8
+ * Two differences from the stage — the title is 36px (the dashboard has a
9
+ * controlled width, so it drops to 28 only on phones, where 36 would break a
10
+ * project name across three lines) and the row gains an actions zone on the
11
+ * right.
12
+ *
13
+ * Spec (§Page header):
14
+ * crumb fz 10 · fw 800 · gray.4 · uppercase · ls 1.5px — where you are
15
+ * title fz 36 · fw 900 · lh 1 · ls -0.04em — what this screen is
16
+ * subtitle fz 15 · fw 500 · gray.6 · lh 1.5 — what you can do
17
+ * actions Group gap md, on the right — ONE primary button
18
+ *
19
+ * One primary button per screen. If there are two equally important actions,
20
+ * one of them isn't.
21
+ */
22
+ export function PageHeader({ crumb, title, subtitle, actions, order = 1, mb = 32 }) {
23
+ const side = useMediaQuery('(min-width: 48em)')
24
+
25
+ /*
26
+ * The crumb on project screens is assembled as `Product · ${project.name}`.
27
+ * On first load the name hasn't arrived yet (ProjectShell hands over a
28
+ * provisional project, with the id from the URL and an empty name), and the
29
+ * interpolation would leave a separator dangling at the end of the line — a
30
+ * " · " pointing at nothing. Trimming it here is worth more than repeating
31
+ * the condition on every screen: the header already owns the crumb's
32
+ * display rule.
33
+ */
34
+ const trilha = typeof crumb === 'string' ? crumb.replace(/\s*·\s*$/, '') : crumb
35
+
36
+ return (
37
+ <Group
38
+ justify="space-between"
39
+ align="flex-start"
40
+ /*
41
+ * On phones the actions drop to the line below instead of squeezing
42
+ * the title against the edge.
43
+ *
44
+ * `wrap` does NOT accept a responsive object: the Group passes it
45
+ * raw into the CSS var `--group-wrap`, and a stringified object is
46
+ * not a valid value. That's why the decision comes from
47
+ * `useMediaQuery`.
48
+ */
49
+ wrap={side ? 'nowrap' : 'wrap'}
50
+ gap={{ base: 16, sm: 24 }}
51
+ mb={mb}
52
+ >
53
+ <Stack
54
+ gap={8}
55
+ /* The `0` in flex-shrink is what forces the wrap: with shrink 1
56
+ the two children shrank until they fit side by side, and the
57
+ title broke one word per line instead of the row wrapping. */
58
+ flex={{ base: '1 0 100%', sm: 1 }}
59
+ miw={0}
60
+ >
61
+ {trilha && (
62
+ <Text
63
+ fz={10}
64
+ fw={800}
65
+ c="gray.4"
66
+ tt="uppercase"
67
+ ls="1.5px"
68
+ lh={1.4}
69
+ >
70
+ {trilha}
71
+ </Text>
72
+ )}
73
+ <Title
74
+ order={order}
75
+ /*
76
+ * THE SIZE COMES FROM THE THEME, not from this line.
77
+ *
78
+ * Mantine's `Title` ignores a responsive `fz`: it always
79
+ * emits `--title-fz: var(--mantine-h1-font-size)`, whatever
80
+ * object you pass. Measured — `{base:28, sm:36}` and
81
+ * `{base:26, sm:32, md:36}` produce identical styling.
82
+ *
83
+ * This matters because three dashboards kept a local copy of
84
+ * this component "because of the different ramp", and the
85
+ * ramp never applied in any of the four. The divergence was
86
+ * only in the source; the screen was always the same.
87
+ *
88
+ * Anyone who wants to change the title scale changes the
89
+ * theme (`headings.sizes.h1`), which is where it actually
90
+ * lives.
91
+ */
92
+ fw={900}
93
+ lh={1}
94
+ ls="-0.04em"
95
+ c="gray.9"
96
+ style={{ overflowWrap: 'anywhere' }}
97
+ >
98
+ {title}
99
+ </Title>
100
+ {subtitle && (
101
+ <Text
102
+ fz={15}
103
+ fw={500}
104
+ c="gray.6"
105
+ lh={1.5}
106
+ >
107
+ {subtitle}
108
+ </Text>
109
+ )}
110
+ </Stack>
111
+
112
+ {actions && (
113
+ <Group
114
+ gap="md"
115
+ wrap="nowrap"
116
+ flex={{ base: '1 0 100%', sm: 'none' }}
117
+ justify={side ? 'flex-end' : 'flex-start'}
118
+ >
119
+ {actions}
120
+ </Group>
121
+ )}
122
+ </Group>
123
+ )
124
+ }
@@ -0,0 +1,88 @@
1
+ import { Alert, Button, Group, Text } from '@mantine/core'
2
+ import { IconAlertTriangle } from '@tabler/icons-react'
3
+
4
+ /**
5
+ * The billing banner: shown when the owner has no active plan of this product.
6
+ *
7
+ * WHAT IT DOES NOT DO is the point of it. It does not block the panel, it does
8
+ * not cover the content, and it can never be the reason somebody fails to reach
9
+ * what they already have. The rule across the stack is FREEZE, NOT DISCONNECT:
10
+ * an expired subscription stops the NEXT thing from being created, and leaves
11
+ * everything already created working — including, in Auth's case, the login of
12
+ * end users who never bought anything from us and could not fix it if they
13
+ * wanted to.
14
+ *
15
+ * That is why the copy says what STILL WORKS before what does not. The first
16
+ * question of anyone who sees this banner is "did my users just go down?", and
17
+ * an answer that opens with the refusal makes them assume the worst.
18
+ *
19
+ * IT TAKES THE STATE, IT DOES NOT FETCH IT. The kit paints; it does not know
20
+ * the address of the Payments. The caller passes what `usePlanNotice(product)`
21
+ * from `@ciromaciel/payments-react` returned, which costs no request of its own —
22
+ * the provider has already loaded it.
23
+ *
24
+ * IT RENDERS NOTHING UNLESS THE STATE IS `none`. `unknown` — still loading, or
25
+ * the call failed — is deliberately silent: dunning somebody because our own
26
+ * billing request has not come back is worse than waiting for it, and it is
27
+ * also what keeps the banner from flashing on every page load.
28
+ *
29
+ * IT IS NOT DISMISSIBLE. A banner with an X is a banner that gets closed once
30
+ * and never seen again, and what it reports does not go away on its own — it
31
+ * goes away when the person pays. The button is the way out.
32
+ *
33
+ * RED, NOT YELLOW. Yellow reads as "look at this when you can"; a lapsed
34
+ * subscription is a wall the person has already hit. The panel's own vocabulary
35
+ * says so: `red.6`/`red.7` is what the kit spends on destructive confirmation,
36
+ * so a viewer who has seen a delete modal already knows this colour means
37
+ * "something is blocked until you act". The light `variant="light"` surface
38
+ * keeps it from shouting over the page it sits on — the banner still must not
39
+ * be the loudest thing on a screen that otherwise works.
40
+ *
41
+ * The button is `red.7` filled with white text, the same pair as the kit's
42
+ * destructive button, so the two never drift apart.
43
+ *
44
+ * @param {'active'|'none'|'unknown'} state What `usePlanNotice` returned.
45
+ * @param {string} message One sentence, in the reader's language. Says what
46
+ * keeps working before what does not.
47
+ * @param {string} [actionLabel]
48
+ * @param {Function} onAction Opens the Billing modal the panel already has.
49
+ */
50
+ export function PlanBanner({ state, message, actionLabel = 'Regularizar assinatura', onAction }) {
51
+ if (state !== 'none') return null
52
+
53
+ return (
54
+ <Alert
55
+ variant="light"
56
+ color="red.6"
57
+ icon={<IconAlertTriangle size={20} />}
58
+ title="Assinatura necessária"
59
+ mb="lg"
60
+ >
61
+ <Group
62
+ justify="space-between"
63
+ align="flex-start"
64
+ wrap="wrap"
65
+ gap="sm"
66
+ >
67
+ <Text
68
+ size="sm"
69
+ style={{ flex: '1 1 320px' }}
70
+ >
71
+ {message}
72
+ </Text>
73
+
74
+ {/* Responsive `w`, not `fullWidth`: Mantine's `fullWidth` is a
75
+ boolean and does not accept breakpoints, so on a phone the
76
+ button would keep its content width in the corner. */}
77
+ <Button
78
+ size="sm"
79
+ color="red.7"
80
+ onClick={onAction}
81
+ w={{ base: '100%', sm: 'auto' }}
82
+ >
83
+ {actionLabel}
84
+ </Button>
85
+ </Group>
86
+ </Alert>
87
+ )
88
+ }
@@ -0,0 +1,93 @@
1
+ import { Box, Text, UnstyledButton } from '@mantine/core'
2
+
3
+ /**
4
+ * Uma linha da lista de recursos na coluna lateral.
5
+ *
6
+ * VEIO DE SEIS CÓPIAS COM QUATRO NOMES: `ProjectItem` (payments, messages,
7
+ * hoster), `InstanceItem` (storage), `ApplicationItem` (auth) e `FunctionItem`
8
+ * (functions). A estrutura era a mesma nas seis — ícone, nome, segunda linha —
9
+ * e o que mudava era só o formato do objeto que cada uma recebia
10
+ * (`project.name`, `tenant.name`, `fn.name`) e o campo da segunda linha.
11
+ *
12
+ * Aqui o componente recebe `title` e `subtitle` já resolvidos. O painel sabe o
13
+ * que é um projeto; o kit não precisa saber.
14
+ *
15
+ * A SEGUNDA LINHA É O QUE DISTINGUE DUAS LINHAS PARECIDAS. No Storage é o
16
+ * ambiente, no Hoster o endereço no ar, no Payments se o projeto consegue
17
+ * cobrar. Sem ela, quatro nomes semelhantes viram quatro linhas iguais — e ela
18
+ * SOME quando não há dado, em vez de mostrar um estado inventado enquanto a
19
+ * resposta não chegou.
20
+ *
21
+ * `minWidth: 0` no botão e no texto não é detalhe: sem ele o conteúdo mais
22
+ * largo que a coluna define a largura mínima e empurra a barra inteira.
23
+ *
24
+ * @param {Function} icon Componente de ícone (tabler).
25
+ * @param {string} title O nome do recurso.
26
+ * @param {string} subtitle A segunda linha. Opcional.
27
+ * @param {'muted'|'alert'} tone `alert` pinta o subtítulo de vermelho — para
28
+ * quando ele É o problema ("SEM GATEWAY").
29
+ * @param {boolean} mono Título monoespaçado, para nome que é identificador.
30
+ * @param {node} trailing O que vai na ponta direita. O Hoster põe o alfinete
31
+ * de fixar; os outros não passam nada.
32
+ * @param {Function} onClick Abrir o recurso.
33
+ */
34
+ export function ResourceItem({ icon: Icon, title, subtitle, tone = 'muted', mono = false, trailing, onClick }) {
35
+ return (
36
+ <UnstyledButton
37
+ onClick={onClick}
38
+ w="100%"
39
+ px={12}
40
+ py={8}
41
+ style={{
42
+ display: 'flex',
43
+ alignItems: 'center',
44
+ justifyContent: 'flex-start',
45
+ gap: 10,
46
+ borderRadius: 0,
47
+ background: 'transparent',
48
+ transition: 'background 120ms ease',
49
+ minWidth: 0,
50
+ maxWidth: '100%',
51
+ }}
52
+ onMouseEnter={e => {
53
+ e.currentTarget.style.background = 'var(--mantine-color-gray-1)'
54
+ }}
55
+ onMouseLeave={e => {
56
+ e.currentTarget.style.background = 'transparent'
57
+ }}
58
+ >
59
+ <Icon
60
+ size={18}
61
+ stroke={1.5}
62
+ style={{ flex: 'none', color: 'var(--mantine-color-gray-5)' }}
63
+ />
64
+
65
+ <Box style={{ flex: 1, minWidth: 0, lineHeight: 1.25, textAlign: 'left' }}>
66
+ <Text
67
+ fz={13}
68
+ fw={500}
69
+ c="gray.9"
70
+ lineClamp={1}
71
+ ff={mono ? 'monospace' : undefined}
72
+ >
73
+ {title}
74
+ </Text>
75
+
76
+ {subtitle && (
77
+ <Text
78
+ fz={9}
79
+ fw={800}
80
+ c={tone === 'alert' ? 'red.6' : 'gray.4'}
81
+ tt="uppercase"
82
+ lts="1.5px"
83
+ lineClamp={1}
84
+ >
85
+ {subtitle}
86
+ </Text>
87
+ )}
88
+ </Box>
89
+
90
+ {trailing}
91
+ </UnstyledButton>
92
+ )
93
+ }
@@ -0,0 +1,212 @@
1
+ import { Box, Card, Divider, Group, Text, Title } from '@mantine/core'
2
+ import { useMediaQuery } from '@mantine/hooks'
3
+
4
+ /**
5
+ * The dashboard's settings block — the same primitive as the auth dashboard
6
+ * (components/Configurations/Shared.jsx) and the storage one (SettingsView).
7
+ *
8
+ * THE TITLE LIVES INSIDE THE CARD. This is the difference that separates a
9
+ * dashboard from a document, and it's worth writing out in full:
10
+ *
11
+ * The hoster opened each block with a SectionHeader — a "01 · ENVIAR
12
+ * ARQUIVOS" label, a 32px title, a 15px description over two lines — and only
13
+ * then the Card with the content. Each of those headers cost ~135px of height
14
+ * before any data, and two sections per screen spent 30% of the viewport saying
15
+ * the names of things. Worse: with the screen's h1 at 36px and the section's at
16
+ * 32px, nothing declared which of the two was the page title.
17
+ *
18
+ * Here the title is 18px and lives in the Card's own header, right up against
19
+ * the content it names. The ratio against the PageHeader is back to 36:18 — the
20
+ * same as auth — and the whole screen fits where half of it used to.
21
+ *
22
+ * The 01/02/03 numbering went with it. It promises a sequence, and parallel
23
+ * settings are not steps of anything; in auth, numbering only shows up in
24
+ * onboarding, where you really do follow one step after another.
25
+ */
26
+ export function SectionCard({ eyebrow, title, lead, action, children }) {
27
+ return (
28
+ <Card
29
+ p={0}
30
+ withBorder
31
+ styles={{ root: { overflow: 'hidden' } }}
32
+ >
33
+ <Group
34
+ justify="space-between"
35
+ align="flex-end"
36
+ px={{ base: 16, sm: 24 }}
37
+ py={20}
38
+ gap={16}
39
+ wrap="nowrap"
40
+ >
41
+ <Box
42
+ miw={0}
43
+ flex={1}
44
+ >
45
+ {/* `span` because the eyebrow isn't always text: the domains
46
+ screen passes the status indicator (glyph + word), and a
47
+ <div> inside the <p> that Text generates by default is
48
+ invalid HTML — the browser closes the paragraph on its own
49
+ and the layout breaks. As a span, any content fits. */}
50
+ {eyebrow && (
51
+ <Text
52
+ component="span"
53
+ display="block"
54
+ fz={10}
55
+ fw={800}
56
+ c="gray.4"
57
+ tt="uppercase"
58
+ mb={6}
59
+ ls="1.5px"
60
+ >
61
+ {eyebrow}
62
+ </Text>
63
+ )}
64
+ <Title
65
+ order={3}
66
+ fz={18}
67
+ fw={800}
68
+ lh={1.2}
69
+ ls="-0.02em"
70
+ c="gray.9"
71
+ >
72
+ {title}
73
+ </Title>
74
+ {lead && (
75
+ <Text
76
+ fz={13}
77
+ fw={500}
78
+ c="gray.6"
79
+ lh={1.5}
80
+ mt={6}
81
+ maw={640}
82
+ >
83
+ {lead}
84
+ </Text>
85
+ )}
86
+ </Box>
87
+ {action && <Box flex="none">{action}</Box>}
88
+ </Group>
89
+
90
+ <Divider />
91
+
92
+ <Box>{children}</Box>
93
+ </Card>
94
+ )
95
+ }
96
+
97
+ /**
98
+ * One row of the block: label on the left, control on the right.
99
+ *
100
+ * `1fr auto` instead of two equal columns — the text is elastic and the control
101
+ * gets the width it needs. `last` suppresses the final border, otherwise the
102
+ * hairline would double up with the Card's frame.
103
+ */
104
+ export function Field({ label, help, control, last = false, align = 'center' }) {
105
+ /*
106
+ * Two columns become one on phones.
107
+ *
108
+ * The `auto` column is the control, and `auto` won't shrink below the
109
+ * control's own width: on a 280px input the label was squeezed to zero and
110
+ * the row overflowed anyway. Stacked, the control gets the full width and
111
+ * the label fits again.
112
+ */
113
+ const side = useMediaQuery('(min-width: 48em)')
114
+ return (
115
+ <Box
116
+ px={{ base: 16, sm: 24 }}
117
+ py={18}
118
+ style={{
119
+ borderBottom: last ? 'none' : 'var(--rlg-border)',
120
+ display: 'grid',
121
+ gridTemplateColumns: side ? '1fr auto' : '1fr',
122
+ gap: side ? 32 : 10,
123
+ alignItems: side ? align : 'stretch',
124
+ }}
125
+ >
126
+ <Box miw={0}>
127
+ <Text
128
+ fz={13}
129
+ fw={700}
130
+ c="gray.9"
131
+ lh={1.3}
132
+ >
133
+ {label}
134
+ </Text>
135
+ {help && (
136
+ <Text
137
+ fz={12}
138
+ fw={500}
139
+ c="gray.6"
140
+ lh={1.5}
141
+ mt={4}
142
+ maw={520}
143
+ >
144
+ {help}
145
+ </Text>
146
+ )}
147
+ </Box>
148
+ <Box style={{ justifySelf: side ? 'end' : 'stretch' }}>{control}</Box>
149
+ </Box>
150
+ )
151
+ }
152
+
153
+ /**
154
+ * The same row, with the control at full width below the label — for whatever
155
+ * doesn't fit on the right: a textarea, a dropzone, a whole table.
156
+ */
157
+ export function FieldStacked({ label, help, control, last = false }) {
158
+ return (
159
+ <Box
160
+ px={{ base: 16, sm: 24 }}
161
+ py={20}
162
+ style={{ borderBottom: last ? 'none' : 'var(--rlg-border)' }}
163
+ >
164
+ {label && (
165
+ <Text
166
+ fz={13}
167
+ fw={700}
168
+ c="gray.9"
169
+ lh={1.3}
170
+ mb={4}
171
+ >
172
+ {label}
173
+ </Text>
174
+ )}
175
+ {help && (
176
+ <Text
177
+ fz={12}
178
+ fw={500}
179
+ c="gray.6"
180
+ lh={1.5}
181
+ mb={12}
182
+ maw={520}
183
+ >
184
+ {help}
185
+ </Text>
186
+ )}
187
+ {control}
188
+ </Box>
189
+ )
190
+ }
191
+
192
+ /**
193
+ * A block's action footer — outside the body, over the gray.1 band.
194
+ *
195
+ * It sits inside the Card, not loose below it: the action belongs to the block
196
+ * it saves, and a button floating between two Cards doesn't say which one it
197
+ * answers to.
198
+ */
199
+ export function CardActions({ children, justify = 'flex-end' }) {
200
+ return (
201
+ <Group
202
+ justify={justify}
203
+ gap={8}
204
+ px={{ base: 16, sm: 24 }}
205
+ py={16}
206
+ bg="gray.1"
207
+ style={{ borderTop: 'var(--rlg-border)' }}
208
+ >
209
+ {children}
210
+ </Group>
211
+ )
212
+ }