@valentinkolb/cloud 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -8
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +119 -47
- package/src/_internal/runtime-context.ts +1 -0
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +15 -25
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +4 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +4 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
package/src/ui/misc/StatCell.tsx
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
1
1
|
import type { JSX } from "solid-js";
|
|
2
|
+
import { Show } from "solid-js";
|
|
3
|
+
import Chart from "./Chart";
|
|
4
|
+
import { type StatGridSize, useStatGridSize } from "./StatGrid";
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
|
-
* Single cell
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
+
* Single cell inside a {@link StatGrid}. Renders one stat: tiny
|
|
8
|
+
* uppercase label, prominent value, and an optional sub line that
|
|
9
|
+
* may carry an inline accent (pill with text, or a plain colored
|
|
10
|
+
* icon).
|
|
11
|
+
*
|
|
12
|
+
* The cell provides its own `bg-white dark:bg-zinc-900` so it works
|
|
13
|
+
* inside `StatGrid`'s hairline-bleed body (`gap-px bg-zinc-100`):
|
|
14
|
+
* each cell's background tile is what hides the bleed except at the
|
|
15
|
+
* 1px gaps, which become the inter-cell dividers. Don't strip the
|
|
16
|
+
* bg unless you're rendering the cell outside a StatGrid.
|
|
7
17
|
*
|
|
8
|
-
* Use inside a parent grid that frames the cells:
|
|
9
18
|
* ```tsx
|
|
10
|
-
* <
|
|
11
|
-
* <
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* <StatGrid columns={3}>
|
|
20
|
+
* <StatCell label="Apps" value={17} sub="9 nav · 12 admin" />
|
|
21
|
+
* <StatCell
|
|
22
|
+
* label="Healthy"
|
|
23
|
+
* value="17/17"
|
|
24
|
+
* accent={{ tone: "emerald", icon: "ti ti-check", text: "ok" }}
|
|
25
|
+
* />
|
|
26
|
+
* <StatCell
|
|
27
|
+
* label="P99"
|
|
28
|
+
* value="89ms"
|
|
29
|
+
* valueClass="text-amber-600 dark:text-amber-400"
|
|
30
|
+
* accent={{ tone: "amber", icon: "ti ti-alert-triangle" }}
|
|
31
|
+
* />
|
|
32
|
+
* </StatGrid>
|
|
21
33
|
* ```
|
|
22
34
|
*
|
|
23
35
|
* Accent rules:
|
|
24
36
|
* - `accent.text` set → renders an icon-and-text pill (`.tag` with bg).
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
37
|
+
* Use for short labels like "+12%" or "ok".
|
|
38
|
+
* - `accent.text` omitted → renders a plain colored icon (no bg). The
|
|
39
|
+
* `.tag` background looks squished around a single icon, so we drop
|
|
40
|
+
* it. Use for status hints next to a colored value.
|
|
41
|
+
* - When the accent should also colour the value itself (warnings,
|
|
42
|
+
* errors), pass `valueClass` like `text-amber-600 dark:text-amber-400`.
|
|
43
|
+
*
|
|
44
|
+
* Pass `href` to make the whole cell a link — adds a subtle hover
|
|
45
|
+
* tint and keeps the cell visually identical when static.
|
|
29
46
|
*/
|
|
30
47
|
export type StatCellAccent = {
|
|
31
48
|
tone: "emerald" | "amber" | "red" | "blue" | "zinc";
|
|
@@ -33,16 +50,49 @@ export type StatCellAccent = {
|
|
|
33
50
|
icon: string;
|
|
34
51
|
/** Optional pill text. If set → tag with bg. If omitted → plain colored icon. */
|
|
35
52
|
text?: string;
|
|
53
|
+
/**
|
|
54
|
+
* When set together with `text`, the pill renders as a link with
|
|
55
|
+
* a tone-matched hover state. Use for "drill into this status"
|
|
56
|
+
* affordances next to the value (e.g. an amber "open" pill that
|
|
57
|
+
* links to the requests page).
|
|
58
|
+
*
|
|
59
|
+
* Ignored when `text` is omitted — an icon-only accent is not a
|
|
60
|
+
* link target. Also incompatible with a cell-level `href`: the
|
|
61
|
+
* resulting `<a>` inside `<a>` is invalid HTML, so this is silently
|
|
62
|
+
* ignored when the parent cell is already a link.
|
|
63
|
+
*/
|
|
64
|
+
href?: string;
|
|
36
65
|
};
|
|
37
66
|
|
|
38
67
|
export type StatCellProps = {
|
|
39
68
|
label: string;
|
|
40
|
-
|
|
41
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Value to display. Accepts JSX so callers can render formatted
|
|
71
|
+
* content (e.g. a number followed by an inline unit, or a mix of
|
|
72
|
+
* sizes). The default styling is `text-xl font-bold tabular-nums`.
|
|
73
|
+
*/
|
|
74
|
+
value: string | number | JSX.Element;
|
|
75
|
+
/** Sub line under the value. */
|
|
42
76
|
sub?: string;
|
|
43
77
|
/** Override the default `text-primary` value colour for warning / error / success signals. */
|
|
44
78
|
valueClass?: string;
|
|
45
79
|
accent?: StatCellAccent;
|
|
80
|
+
/** When set, the whole cell becomes a link to this URL with a subtle hover state. */
|
|
81
|
+
href?: string;
|
|
82
|
+
/** Native `title` attribute on the value — useful when the value is truncated. */
|
|
83
|
+
title?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Optional inline sparkline showing the value's recent history.
|
|
86
|
+
* Plain `number[]`, oldest → newest. Renders below the sub row at
|
|
87
|
+
* a fixed compact height; the line tone matches the cell's value
|
|
88
|
+
* tone (uses `currentColor` on a wrapper). Pass an empty array or
|
|
89
|
+
* omit to hide the sparkline.
|
|
90
|
+
*/
|
|
91
|
+
trend?: number[];
|
|
92
|
+
/**
|
|
93
|
+
* Optional per-cell scale. Defaults to the parent StatGrid `size`.
|
|
94
|
+
*/
|
|
95
|
+
size?: StatGridSize;
|
|
46
96
|
};
|
|
47
97
|
|
|
48
98
|
const ACCENT_PILL_CLASSES: Record<StatCellAccent["tone"], string> = {
|
|
@@ -61,28 +111,120 @@ const ACCENT_ICON_CLASSES: Record<StatCellAccent["tone"], string> = {
|
|
|
61
111
|
zinc: "text-zinc-500 dark:text-zinc-400",
|
|
62
112
|
};
|
|
63
113
|
|
|
64
|
-
|
|
114
|
+
/** Hover tone for a clickable accent pill — one shade darker than the
|
|
115
|
+
* resting state. Same tone family, no surprise jump to a different
|
|
116
|
+
* hue on hover. */
|
|
117
|
+
const ACCENT_PILL_HOVER_CLASSES: Record<StatCellAccent["tone"], string> = {
|
|
118
|
+
emerald: "hover:bg-emerald-200 dark:hover:bg-emerald-900/60",
|
|
119
|
+
amber: "hover:bg-amber-200 dark:hover:bg-amber-900/60",
|
|
120
|
+
red: "hover:bg-red-200 dark:hover:bg-red-900/60",
|
|
121
|
+
blue: "hover:bg-blue-200 dark:hover:bg-blue-900/60",
|
|
122
|
+
zinc: "hover:bg-zinc-200 dark:hover:bg-zinc-700",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Cell body — same markup whether the wrapper is a `<div>` or `<a>`.
|
|
126
|
+
* `cellIsLink` is set by the wrapper to suppress nested-link rendering
|
|
127
|
+
* (an `<a>` inside an `<a>` is invalid HTML). When true, an
|
|
128
|
+
* `accent.href` falls back to a static span pill. */
|
|
129
|
+
const Body = (props: StatCellProps & { cellIsLink: boolean }): JSX.Element => {
|
|
65
130
|
const valueClass = props.valueClass ?? "text-primary";
|
|
66
|
-
const
|
|
131
|
+
const gridSize = useStatGridSize();
|
|
132
|
+
const size = () => props.size ?? gridSize;
|
|
133
|
+
const valueSizeClass = () => (size() === "sm" ? "text-base" : "text-xl");
|
|
134
|
+
const labelSizeClass = () => (size() === "sm" ? "text-[9px]" : "text-[10px]");
|
|
135
|
+
const subSizeClass = () => (size() === "sm" ? "text-[9px]" : "text-[10px]");
|
|
67
136
|
return (
|
|
68
|
-
|
|
69
|
-
<span class=
|
|
70
|
-
<span class={
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
|
|
137
|
+
<>
|
|
138
|
+
<span class={`${labelSizeClass()} uppercase tracking-wider text-dimmed truncate`}>{props.label}</span>
|
|
139
|
+
<span class={`${valueSizeClass()} font-bold tabular-nums leading-tight truncate ${valueClass}`} title={props.title}>
|
|
140
|
+
{props.value}
|
|
141
|
+
</span>
|
|
142
|
+
{/* Optional trend sparkline. Sits inline between the value and
|
|
143
|
+
the sub row so the eye lands on it right after parsing the
|
|
144
|
+
headline number.
|
|
145
|
+
|
|
146
|
+
Sizing: inline `style` for the height (Tailwind's h-8 class
|
|
147
|
+
didn't survive every hot-reload / cache combo in the wild;
|
|
148
|
+
inline style is immune). Full-bleed horizontally via -mx-4
|
|
149
|
+
to cancel the cell's px-4 padding so the line spans the
|
|
150
|
+
card edge-to-edge — the inset visual reads as "tucked-in
|
|
151
|
+
line in the middle", which the user reported as "doesn't
|
|
152
|
+
fit" against the surrounding card.
|
|
153
|
+
|
|
154
|
+
`showLast` + `showMinMax` add dots at the most-recent point
|
|
155
|
+
plus the min/max points so a flat-with-spikes series still
|
|
156
|
+
has visible anchor points (otherwise sparse data renders
|
|
157
|
+
as nearly-invisible horizontal lines). */}
|
|
158
|
+
<Show when={props.trend && props.trend.length > 1}>
|
|
159
|
+
<Chart kind="sparkline" class="-mx-4 self-stretch block" style={{ height: "32px" }} data={props.trend ?? []} showLast showMinMax />
|
|
160
|
+
</Show>
|
|
161
|
+
{/* Sub row: rendered only when there's actual content. Keeping
|
|
162
|
+
the row out entirely when both sub and accent are absent
|
|
163
|
+
lets the grid's row-height shrink naturally — callers that
|
|
164
|
+
want forced equal heights should pass `sub=" "`. */}
|
|
165
|
+
{props.sub || props.accent ? (
|
|
166
|
+
<div class="flex items-center gap-1.5 min-w-0">
|
|
167
|
+
{props.sub ? <span class={`${subSizeClass()} text-dimmed truncate`}>{props.sub}</span> : null}
|
|
168
|
+
{props.accent ? (
|
|
169
|
+
props.accent.text ? (
|
|
170
|
+
// Pill variant. `accent.href` upgrades the span to an
|
|
171
|
+
// anchor with a tone-matched hover background, but only
|
|
172
|
+
// when the surrounding cell isn't already a link — the
|
|
173
|
+
// browser refuses to nest `<a>` and silently flattens
|
|
174
|
+
// the inner one, which would look broken on hover.
|
|
175
|
+
props.accent.href && !props.cellIsLink ? (
|
|
176
|
+
<a
|
|
177
|
+
href={props.accent.href}
|
|
178
|
+
class={`tag shrink-0 transition-colors ${ACCENT_PILL_CLASSES[props.accent.tone]} ${ACCENT_PILL_HOVER_CLASSES[props.accent.tone]}`}
|
|
179
|
+
>
|
|
180
|
+
<i class={`${props.accent.icon} text-[9px]`} />
|
|
181
|
+
{props.accent.text}
|
|
182
|
+
</a>
|
|
183
|
+
) : (
|
|
184
|
+
<span class={`tag shrink-0 ${ACCENT_PILL_CLASSES[props.accent.tone]}`}>
|
|
185
|
+
<i class={`${props.accent.icon} text-[9px]`} />
|
|
186
|
+
{props.accent.text}
|
|
187
|
+
</span>
|
|
188
|
+
)
|
|
189
|
+
) : (
|
|
190
|
+
<i class={`${props.accent.icon} ${ACCENT_ICON_CLASSES[props.accent.tone]} text-[11px] shrink-0`} />
|
|
191
|
+
)
|
|
192
|
+
) : null}
|
|
82
193
|
</div>
|
|
83
|
-
) :
|
|
84
|
-
|
|
85
|
-
|
|
194
|
+
) : null}
|
|
195
|
+
</>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const StatCell = (props: StatCellProps): JSX.Element => {
|
|
200
|
+
// Static layout classes — shared between link and non-link wrapper.
|
|
201
|
+
// `bg-white` (and dark equivalent) is what tiles over the parent
|
|
202
|
+
// grid's `bg-zinc-100` bleed; without it the cell would look
|
|
203
|
+
// transparent on top of the divider colour.
|
|
204
|
+
const gridSize = useStatGridSize();
|
|
205
|
+
const size = () => props.size ?? gridSize;
|
|
206
|
+
const baseClass = () => `bg-white dark:bg-zinc-900 flex flex-col gap-0.5 min-w-0 ${size() === "sm" ? "px-3 py-2.5" : "px-4 py-4"}`;
|
|
207
|
+
if (props.href) {
|
|
208
|
+
// Link variant: adds a subtle top-right `external-link` icon as
|
|
209
|
+
// an affordance — sits in dimmed zinc by default, shifts to the
|
|
210
|
+
// link-blue colour on cell hover. `group` lets the icon respond
|
|
211
|
+
// to the whole cell's hover state, not just its own. `pr-7`
|
|
212
|
+
// reserves space so a long truncate'd label can't slide under
|
|
213
|
+
// the icon (the icon is absolute, so it doesn't take a column
|
|
214
|
+
// in the flex layout).
|
|
215
|
+
return (
|
|
216
|
+
<a href={props.href} class={`${baseClass()} group relative pr-7 hover:bg-zinc-50 dark:hover:bg-zinc-800/40 transition-colors`}>
|
|
217
|
+
<i
|
|
218
|
+
class="ti ti-external-link absolute top-2 right-2 text-[11px] text-dimmed group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
|
|
219
|
+
aria-hidden="true"
|
|
220
|
+
/>
|
|
221
|
+
<Body {...props} cellIsLink />
|
|
222
|
+
</a>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return (
|
|
226
|
+
<div class={baseClass()}>
|
|
227
|
+
<Body {...props} cellIsLink={false} />
|
|
86
228
|
</div>
|
|
87
229
|
);
|
|
88
230
|
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { JSX } from "solid-js";
|
|
2
|
+
import { Show, createContext, useContext } from "solid-js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* StatGrid — paper-framed container for a row of {@link StatCell}s.
|
|
6
|
+
*
|
|
7
|
+
* Replaces the inline `paper + grid + gap-px + p-px + bg-zinc` pattern
|
|
8
|
+
* that previously lived in every consumer. The two visual bugs that
|
|
9
|
+
* pattern produced are fixed here:
|
|
10
|
+
*
|
|
11
|
+
* 1. **No more doubled outer border.** The old pattern used
|
|
12
|
+
* `p-px bg-zinc-100` to draw a 1px ring around the cells, which
|
|
13
|
+
* overlapped the `paper` border (also 1px zinc-100) and made the
|
|
14
|
+
* outer edge look thicker than the inner dividers. We drop the
|
|
15
|
+
* `p-px` entirely — the cells touch the paper's inner edge
|
|
16
|
+
* directly, and `paper`'s own border is the only outer line.
|
|
17
|
+
* 2. **No more squashed inner corners.** Without the inner ring, the
|
|
18
|
+
* cells are simply clipped by `paper`'s `rounded-lg overflow-hidden`,
|
|
19
|
+
* so the cell corners match the outer radius cleanly.
|
|
20
|
+
*
|
|
21
|
+
* The hairline dividers between cells come from the standard
|
|
22
|
+
* `gap-px bg-zinc-100` bleed trick: the body's background shows
|
|
23
|
+
* through the 1px gaps between cell `bg-white` tiles. This is why
|
|
24
|
+
* `StatCell` ships its own `bg-white` — don't strip it.
|
|
25
|
+
*
|
|
26
|
+
* ## API
|
|
27
|
+
*
|
|
28
|
+
* Composition-based, mirroring `Widget` / `WidgetStat` and most other
|
|
29
|
+
* platform containers:
|
|
30
|
+
*
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <StatGrid
|
|
33
|
+
* columns={3}
|
|
34
|
+
* title="View totals"
|
|
35
|
+
* action={{ label: "Open full view", href: "/app/grids/abc" }}
|
|
36
|
+
* >
|
|
37
|
+
* <StatCell label="Apps" value={17} />
|
|
38
|
+
* <StatCell label="Routes" value={106} />
|
|
39
|
+
* <StatCell label="Search" value={5} sub="providers" />
|
|
40
|
+
* </StatGrid>
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* ## Columns
|
|
44
|
+
*
|
|
45
|
+
* `columns` (1-6) picks a responsive grid track count from a static
|
|
46
|
+
* map below — Tailwind's JIT only compiles class names it can find
|
|
47
|
+
* literally in source, so interpolated `grid-cols-${n}` strings get
|
|
48
|
+
* stripped silently. Pass any number outside 1-6 and we fall back to
|
|
49
|
+
* the same `grid-cols-2 sm:grid-cols-3 md:grid-cols-6` ladder the
|
|
50
|
+
* grids app uses for its view-stats rows.
|
|
51
|
+
*
|
|
52
|
+
* When `columns` is omitted, callers get a sensible default for a
|
|
53
|
+
* mixed-count row. Pass it explicitly when you know the cell count
|
|
54
|
+
* statically — that's almost always.
|
|
55
|
+
*/
|
|
56
|
+
type StatGridAction = {
|
|
57
|
+
label: string;
|
|
58
|
+
href: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type StatGridSize = "md" | "sm";
|
|
62
|
+
|
|
63
|
+
const StatGridSizeContext = createContext<StatGridSize>("md");
|
|
64
|
+
|
|
65
|
+
export const useStatGridSize = () => useContext(StatGridSizeContext);
|
|
66
|
+
|
|
67
|
+
type StatGridProps = {
|
|
68
|
+
children: JSX.Element;
|
|
69
|
+
/**
|
|
70
|
+
* Optional title shown in a small header bar above the cells.
|
|
71
|
+
* When set, the header gets a `border-b` divider — same colour as
|
|
72
|
+
* the cell hairlines, so it visually continues the grid.
|
|
73
|
+
*/
|
|
74
|
+
title?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Optional right-aligned link in the header. Shows up only when
|
|
77
|
+
* `title` is also set (a lone link with no title would float
|
|
78
|
+
* orphaned).
|
|
79
|
+
*/
|
|
80
|
+
action?: StatGridAction;
|
|
81
|
+
/**
|
|
82
|
+
* Number of columns at the widest breakpoint. Maps to a static
|
|
83
|
+
* responsive class set (see {@link GRID_COLS_CLASS}). Values
|
|
84
|
+
* outside 1-6 fall back to the 6-column ladder.
|
|
85
|
+
*/
|
|
86
|
+
columns?: number;
|
|
87
|
+
/**
|
|
88
|
+
* Compact cells for secondary stats inside dense app surfaces. The
|
|
89
|
+
* default keeps the established admin/dashboard scale.
|
|
90
|
+
*/
|
|
91
|
+
size?: StatGridSize;
|
|
92
|
+
/**
|
|
93
|
+
* Extra classes on the outer paper element — primarily for sizing
|
|
94
|
+
* (`h-full`, `flex-1`) when the grid needs to fill a parent
|
|
95
|
+
* container rather than collapse to its natural content height.
|
|
96
|
+
*/
|
|
97
|
+
class?: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Static responsive column classes. Keys 1-6 map to the responsive
|
|
102
|
+
* ladders used across the platform (matches the grids `StatsRow` /
|
|
103
|
+
* `ViewStatsRow` originals so the visual rhythm is unchanged).
|
|
104
|
+
*
|
|
105
|
+
* The values are literal class strings so Tailwind's JIT picks them
|
|
106
|
+
* up — never inline an interpolation like `md:grid-cols-${n}`.
|
|
107
|
+
*/
|
|
108
|
+
const GRID_COLS_CLASS: Record<number, string> = {
|
|
109
|
+
1: "grid-cols-1",
|
|
110
|
+
2: "grid-cols-2",
|
|
111
|
+
3: "grid-cols-1 sm:grid-cols-3",
|
|
112
|
+
4: "grid-cols-2 md:grid-cols-4",
|
|
113
|
+
5: "grid-cols-2 sm:grid-cols-3 md:grid-cols-5",
|
|
114
|
+
6: "grid-cols-2 sm:grid-cols-3 md:grid-cols-6",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const DEFAULT_GRID_COLS = "grid-cols-2 sm:grid-cols-3 md:grid-cols-6";
|
|
118
|
+
|
|
119
|
+
const StatGrid = (props: StatGridProps): JSX.Element => {
|
|
120
|
+
const gridCols = () => (props.columns ? (GRID_COLS_CLASS[props.columns] ?? DEFAULT_GRID_COLS) : DEFAULT_GRID_COLS);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div class={`paper overflow-hidden flex flex-col ${props.class ?? ""}`}>
|
|
124
|
+
<Show when={props.title}>
|
|
125
|
+
<header class="px-3 py-2 flex items-center justify-between gap-2 border-b border-zinc-100 dark:border-zinc-800/60">
|
|
126
|
+
<span class="text-xs font-semibold text-primary truncate">{props.title}</span>
|
|
127
|
+
<Show when={props.action}>
|
|
128
|
+
{(action) => (
|
|
129
|
+
<a href={action().href} class="text-[11px] text-dimmed hover:text-primary inline-flex items-center gap-1 shrink-0">
|
|
130
|
+
<span>{action().label}</span>
|
|
131
|
+
<i class="ti ti-arrow-up-right text-[10px]" />
|
|
132
|
+
</a>
|
|
133
|
+
)}
|
|
134
|
+
</Show>
|
|
135
|
+
</header>
|
|
136
|
+
</Show>
|
|
137
|
+
{/* Cell grid: `gap-px` carves 1px channels between cells, the
|
|
138
|
+
body `bg-zinc-100` bleeds through those channels, and each
|
|
139
|
+
cell's own `bg-white` covers the rest. No `p-px` — see the
|
|
140
|
+
docblock for why. `flex-1` lets the grid expand to fill the
|
|
141
|
+
paper when the caller passes a sizing class like `h-full`. */}
|
|
142
|
+
<StatGridSizeContext.Provider value={props.size ?? "md"}>
|
|
143
|
+
<div class={`grid ${gridCols()} gap-px bg-zinc-100 dark:bg-zinc-800 flex-1`}>{props.children}</div>
|
|
144
|
+
</StatGridSizeContext.Provider>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export default StatGrid;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createMemo, createSignal, For, Show } from "solid-js";
|
|
2
|
+
import CopyButton from "./CopyButton";
|
|
3
|
+
|
|
4
|
+
export type StructuredDataPreviewMode = "formatted" | "raw";
|
|
5
|
+
|
|
6
|
+
export type StructuredDataPreviewProps = {
|
|
7
|
+
title?: string;
|
|
8
|
+
data: unknown;
|
|
9
|
+
defaultMode?: StructuredDataPreviewMode;
|
|
10
|
+
copy?: boolean;
|
|
11
|
+
empty?: string;
|
|
12
|
+
maxRows?: number;
|
|
13
|
+
class?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Row = {
|
|
17
|
+
key: string;
|
|
18
|
+
value: unknown;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const toRows = (data: unknown): Row[] => {
|
|
22
|
+
if (Array.isArray(data)) return data.map((value, index) => ({ key: String(index), value }));
|
|
23
|
+
if (data && typeof data === "object") return Object.entries(data as Record<string, unknown>).map(([key, value]) => ({ key, value }));
|
|
24
|
+
if (data === null || data === undefined) return [];
|
|
25
|
+
return [{ key: "value", value: data }];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const formatInlineValue = (value: unknown): string => {
|
|
29
|
+
if (value === null || value === undefined) return "null";
|
|
30
|
+
if (typeof value === "string") return value;
|
|
31
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
32
|
+
return JSON.stringify(value);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const formatJson = (data: unknown): string => JSON.stringify(data ?? null, null, 2);
|
|
36
|
+
|
|
37
|
+
export default function StructuredDataPreview(props: StructuredDataPreviewProps) {
|
|
38
|
+
const [mode, setMode] = createSignal<StructuredDataPreviewMode>(props.defaultMode ?? "formatted");
|
|
39
|
+
const rows = createMemo(() => toRows(props.data));
|
|
40
|
+
const visibleRows = createMemo(() => rows().slice(0, props.maxRows ?? rows().length));
|
|
41
|
+
const hiddenCount = createMemo(() => Math.max(0, rows().length - visibleRows().length));
|
|
42
|
+
const raw = createMemo(() => formatJson(props.data));
|
|
43
|
+
const hasData = createMemo(() => rows().length > 0);
|
|
44
|
+
const showRaw = createMemo(() => mode() === "raw");
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div class={["flex flex-col gap-2", props.class].filter(Boolean).join(" ")}>
|
|
48
|
+
<Show when={props.title}>
|
|
49
|
+
{(title) => <h3 class="text-xs font-semibold uppercase tracking-wider text-secondary">{title()}</h3>}
|
|
50
|
+
</Show>
|
|
51
|
+
|
|
52
|
+
<Show
|
|
53
|
+
when={!showRaw()}
|
|
54
|
+
fallback={
|
|
55
|
+
<div class="relative rounded-lg bg-zinc-100 px-3 py-2 text-secondary dark:bg-zinc-900/80">
|
|
56
|
+
<pre class="max-h-72 overflow-auto whitespace-pre-wrap break-all pr-16 font-mono text-[11px] leading-relaxed">{raw()}</pre>
|
|
57
|
+
<Show when={props.copy !== false}>
|
|
58
|
+
<div class="absolute right-2 top-2">
|
|
59
|
+
<CopyButton text={raw()} label="Copy" class="text-[11px] text-dimmed transition-colors hover:text-secondary" />
|
|
60
|
+
</div>
|
|
61
|
+
</Show>
|
|
62
|
+
</div>
|
|
63
|
+
}
|
|
64
|
+
>
|
|
65
|
+
<div class="rounded-lg bg-zinc-100 px-3 py-2 dark:bg-zinc-900/80">
|
|
66
|
+
<Show
|
|
67
|
+
when={hasData()}
|
|
68
|
+
fallback={<p class="text-xs text-dimmed">{props.empty ?? "No data."}</p>}
|
|
69
|
+
>
|
|
70
|
+
<div class="grid grid-cols-[minmax(7rem,auto)_1fr] gap-x-4 gap-y-1.5 text-xs">
|
|
71
|
+
<For each={visibleRows()}>
|
|
72
|
+
{(row) => {
|
|
73
|
+
const complex = typeof row.value === "object" && row.value !== null;
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<span class="min-w-0 truncate font-medium text-dimmed" title={row.key}>
|
|
77
|
+
{row.key}
|
|
78
|
+
</span>
|
|
79
|
+
<span class={`min-w-0 break-all text-secondary ${complex ? "font-mono text-[11px]" : ""}`}>
|
|
80
|
+
{formatInlineValue(row.value)}
|
|
81
|
+
</span>
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
}}
|
|
85
|
+
</For>
|
|
86
|
+
</div>
|
|
87
|
+
<Show when={hiddenCount() > 0}>
|
|
88
|
+
<p class="mt-2 text-[11px] text-dimmed">{hiddenCount()} more row{hiddenCount() === 1 ? "" : "s"} hidden.</p>
|
|
89
|
+
</Show>
|
|
90
|
+
</Show>
|
|
91
|
+
</div>
|
|
92
|
+
</Show>
|
|
93
|
+
|
|
94
|
+
<div class="flex items-center gap-2">
|
|
95
|
+
<Show when={hasData()}>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
class="text-[11px] text-dimmed transition-colors hover:text-secondary"
|
|
99
|
+
onClick={() => setMode(showRaw() ? "formatted" : "raw")}
|
|
100
|
+
>
|
|
101
|
+
{showRaw() ? "View formatted" : "View raw"}
|
|
102
|
+
</button>
|
|
103
|
+
</Show>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|